GitHub Actions: Advanced Triggers and Events

Push and pull_request are just the beginning. Here's how to trigger workflows from Slack, schedule nightly builds, chain workflows together, and turn GitHub into a surprisingly capable automation platform.

Most GitHub Actions workflows I see in the wild start the same way: on: [push, pull_request]. And look, that’s fine. It covers 80% of use cases. But if you stop there, you’re treating a full-blown automation platform like a simple CI runner.

In the first post of this series, I covered what’s possible with Actions. Now let’s dig into when those workflows can run. The trigger system is more powerful than most people realize—and once you understand it, GitHub starts looking less like a code host and more like a general-purpose event-driven automation platform.

The Trigger Landscape

Before we dive into specifics, here’s the lay of the land. GitHub Actions can be triggered by:

  • Code events: push, pull_request, create, delete, fork
  • Manual triggers: workflow_dispatch, repository_dispatch
  • Scheduled events: cron expressions
  • Workflow events: workflow_run, workflow_call
  • Release and deployment events: release, deployment, deployment_status
  • Issue and PR events: issues, issue_comment, pull_request_review
  • External events: repository_dispatch from any HTTP client

Let’s work through the interesting ones.

workflow_dispatch: The Big Red Button

Sometimes you want a human to push the button. That’s workflow_dispatch—a manual trigger with optional inputs.

name: Deploy to Production

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - staging
          - production
      skip_tests:
        description: 'Skip test suite'
        required: false
        type: boolean
        default: false
      version:
        description: 'Version to deploy (leave empty for latest)'
        required: false
        type: string

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.environment }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.inputs.version || github.ref }}

      - name: Run tests
        if: ${{ github.event.inputs.skip_tests != 'true' }}
        run: npm test

      - name: Deploy
        run: |
          echo "Deploying to ${{ github.event.inputs.environment }}"
          ./deploy.sh ${{ github.event.inputs.environment }}

This creates a “Run workflow” button in the GitHub UI with a dropdown for environment, a checkbox for skipping tests, and a text field for the version.

Input Types

You get five input types to work with:

  • string: Free-form text
  • choice: Dropdown with predefined options
  • boolean: Checkbox (becomes the string ’true’ or ‘false’)
  • number: Numeric input
  • environment: Special type that lists your repository’s environments

The environment type is particularly nice because it integrates with GitHub’s environment protection rules. You can require approvals for production deployments right from the input selection.

Accessing Inputs

Inputs are available via github.event.inputs.<name>. One gotcha: boolean inputs come through as strings. You need to compare against 'true' and 'false', not actual booleans.

- name: This won't work as expected
  if: ${{ github.event.inputs.skip_tests }}  # Always true if input exists!
  run: echo "Skipping..."

- name: This works
  if: ${{ github.event.inputs.skip_tests == 'true' }}
  run: echo "Skipping..."

Ask me how I learned this. Actually, don’t—it’s embarrassing.

repository_dispatch: Trigger From Anywhere

workflow_dispatch is for humans clicking buttons. repository_dispatch is for machines calling your workflows from outside GitHub.

name: External Trigger Handler

on:
  repository_dispatch:
    types:
      - deploy
      - rollback
      - rebuild-cache

jobs:
  handle-event:
    runs-on: ubuntu-latest
    steps:
      - name: Handle deploy
        if: github.event.action == 'deploy'
        run: |
          echo "Deploying version ${{ github.event.client_payload.version }}"
          echo "Requested by ${{ github.event.client_payload.requested_by }}"

      - name: Handle rollback
        if: github.event.action == 'rollback'
        run: |
          echo "Rolling back to ${{ github.event.client_payload.target_version }}"

To trigger this, you send a POST request to GitHub’s API:

curl -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  https://api.github.com/repos/owner/repo/dispatches \
  -d '{
    "event_type": "deploy",
    "client_payload": {
      "version": "v2.3.1",
      "requested_by": "slack-bot",
      "environment": "production"
    }
  }'

The client_payload is completely freeform—you can stuff whatever JSON you want in there, and it’s available in the workflow as github.event.client_payload.

Slack-Triggered Deployments

One of my favorite patterns: triggering deployments from Slack. Here’s the flow:

  1. User types /deploy production v2.3.1 in Slack
  2. Slack sends the command to your serverless function (Lambda, Cloudflare Worker, whatever)
  3. Your function validates the user has permission, then calls repository_dispatch
  4. GitHub Actions runs the deployment
  5. Your function posts the workflow URL back to Slack

The function that glues Slack to GitHub is maybe 50 lines of code:

// Cloudflare Worker example
async function handleSlackCommand(request) {
  const formData = await request.formData();
  const command = formData.get('text'); // "production v2.3.1"
  const userId = formData.get('user_id');

  // Parse command
  const [environment, version] = command.split(' ');

  // Trigger GitHub workflow
  const response = await fetch(
    'https://api.github.com/repos/owner/repo/dispatches',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${GITHUB_TOKEN}`,
        'Accept': 'application/vnd.github+json',
      },
      body: JSON.stringify({
        event_type: 'deploy',
        client_payload: {
          environment,
          version,
          requested_by: userId,
        },
      }),
    }
  );

  return new Response(
    `Deployment triggered for ${version} to ${environment}. Check Actions for status.`
  );
}

It’s a bit of glue code, but the result is magical: anyone with Slack access can deploy, the actual deployment logic lives in version control, and there’s a full audit trail in GitHub’s workflow history.

schedule: Cron Jobs, GitHub-Style

The schedule trigger lets you run workflows on a cron schedule. It’s exactly what you’d expect, with a few quirks worth knowing about.

name: Nightly Build

on:
  schedule:
    # Every day at 3 AM UTC
    - cron: '0 3 * * *'

    # Also on Mondays at 9 AM UTC (you can have multiple schedules)
    - cron: '0 9 * * 1'

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

The Quirks

First, the annoying one: scheduled workflows only run on the default branch. If you’re developing a scheduled workflow on a feature branch, you can’t test it by waiting for the schedule—you have to merge it first or trigger it manually.

Second: schedules are approximate. GitHub doesn’t guarantee exact timing. During high-load periods, your 3 AM job might run at 3:15 AM. Don’t build anything that requires precise timing.

Third: inactive repositories lose their schedules. If there’s no activity in a repo for 60 days, GitHub disables scheduled workflows. You’ll get an email, but it’s easy to miss.

A Smarter Nightly Build

Here’s a pattern I like: a nightly build that only actually runs if there were changes since the last run.

name: Nightly Build (Smart)

on:
  schedule:
    - cron: '0 3 * * *'

jobs:
  check-changes:
    runs-on: ubuntu-latest
    outputs:
      has_changes: ${{ steps.check.outputs.has_changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check for changes in last 24 hours
        id: check
        run: |
          CHANGES=$(git log --oneline --since="24 hours ago" | wc -l)
          if [ "$CHANGES" -gt 0 ]; then
            echo "has_changes=true" >> $GITHUB_OUTPUT
          else
            echo "has_changes=false" >> $GITHUB_OUTPUT
          fi

  build:
    needs: check-changes
    if: needs.check-changes.outputs.has_changes == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
      - run: npm test

This saves runner minutes on quiet repos. Is it strictly necessary? No. But I appreciate not wasting resources on no-op builds.

workflow_run: Chaining Workflows

Sometimes you want workflow B to run after workflow A completes. That’s workflow_run.

name: Deploy After Tests

on:
  workflow_run:
    workflows: ["CI Tests"]
    types:
      - completed

jobs:
  deploy:
    # Only deploy if the triggering workflow succeeded
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Checkout the same commit that was tested
          ref: ${{ github.event.workflow_run.head_sha }}

      - name: Deploy
        run: ./deploy.sh

Why Not Just Put Everything in One Workflow?

Good question. A few reasons:

  1. Separation of concerns: Different teams might own different workflows
  2. Conditional execution: The second workflow can have different trigger conditions
  3. Secrets and permissions: You can grant different permissions to different workflows
  4. Fan-out patterns: Multiple workflows can trigger from one parent

Downloading Artifacts From the Parent Workflow

If workflow A uploaded artifacts, workflow B can download them—but it’s a bit awkward. You need to use the GitHub API because the normal actions/download-artifact doesn’t work across workflow runs.

- name: Download artifact from parent workflow
  uses: actions/github-script@v7
  with:
    script: |
      const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
        owner: context.repo.owner,
        repo: context.repo.repo,
        run_id: ${{ github.event.workflow_run.id }},
      });

      const matchArtifact = artifacts.data.artifacts.find(
        artifact => artifact.name === 'build-output'
      );

      const download = await github.rest.actions.downloadArtifact({
        owner: context.repo.owner,
        repo: context.repo.repo,
        artifact_id: matchArtifact.id,
        archive_format: 'zip',
      });

      const fs = require('fs');
      fs.writeFileSync('artifact.zip', Buffer.from(download.data));

- name: Extract artifact
  run: unzip artifact.zip

Yeah, it’s verbose. The alternative is to push your build output somewhere external (S3, a registry, whatever) and have the second workflow pull from there. Sometimes that’s cleaner.

Release and Deployment Events

When you publish a release in GitHub (either through the UI or the API), it can trigger workflows.

name: Publish to npm

on:
  release:
    types:
      - published

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.release.tag_name }}

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The release event gives you access to:

  • github.event.release.tag_name: The tag (e.g., “v1.2.3”)
  • github.event.release.name: The release title
  • github.event.release.body: The release notes
  • github.event.release.prerelease: Boolean for pre-releases
  • github.event.release.draft: Boolean for draft releases

You can filter by release type:

on:
  release:
    types:
      - published   # When a release is published
      - created     # When a release is created (including drafts)
      - edited      # When a release is edited
      - prereleased # When a pre-release is published
      - released    # When a non-pre-release is published

The distinction between published and released matters if you use pre-releases. published fires for both pre-releases and full releases; released only fires for full releases.

Issue and Comment Triggers: Building Bots

This is where things get fun. You can trigger workflows based on issue and comment activity, effectively building bots that respond to GitHub activity.

Auto-Triage Based on Labels

name: Issue Triage

on:
  issues:
    types:
      - labeled

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - name: Assign to security team
        if: github.event.label.name == 'security'
        uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.addAssignees({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              assignees: ['security-lead']
            });

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: 'This issue has been flagged for security review.'
            });

      - name: Add to high-priority project
        if: github.event.label.name == 'critical'
        uses: actions/github-script@v7
        with:
          script: |
            // Add to GitHub Project (v2)
            // ... project mutation goes here

Comment-Driven Commands

You can build a command parser that responds to comments on issues or PRs.

name: Comment Commands

on:
  issue_comment:
    types:
      - created

jobs:
  command:
    # Only run on PR comments (issue_comment fires for both issues and PRs)
    if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/')
    runs-on: ubuntu-latest
    steps:
      - name: Parse command
        id: parse
        run: |
          COMMENT="${{ github.event.comment.body }}"
          COMMAND=$(echo "$COMMENT" | head -n1 | cut -d' ' -f1)
          ARGS=$(echo "$COMMENT" | head -n1 | cut -d' ' -f2-)
          echo "command=$COMMAND" >> $GITHUB_OUTPUT
          echo "args=$ARGS" >> $GITHUB_OUTPUT

      - name: Handle /deploy
        if: steps.parse.outputs.command == '/deploy'
        run: |
          echo "Deploying PR ${{ github.event.issue.number }} to preview environment"
          # Deployment logic here

      - name: Handle /benchmark
        if: steps.parse.outputs.command == '/benchmark'
        uses: actions/checkout@v4
        # ... run benchmarks and post results as comment

      - name: Handle /approve
        if: steps.parse.outputs.command == '/approve'
        uses: actions/github-script@v7
        with:
          script: |
            // Check if commenter is authorized
            const authorized = ['maintainer1', 'maintainer2'];
            if (!authorized.includes(context.actor)) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: 'Sorry, you are not authorized to approve PRs.'
              });
              return;
            }

            // Approve the PR
            await github.rest.pulls.createReview({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
              event: 'APPROVE',
              body: 'Approved via /approve command.'
            });

This pattern is powerful. You can build /rebase, /merge, /label bug, /assign @someone—whatever makes sense for your workflow.

Path and Branch Filters

Not every push should trigger every workflow. Filters let you target specific paths and branches.

name: Frontend Tests

on:
  push:
    branches:
      - main
      - 'feature/**'
      - 'release/*'
    paths:
      - 'frontend/**'
      - 'package.json'
      - 'package-lock.json'
    paths-ignore:
      - 'frontend/**/*.md'
      - 'frontend/docs/**'

  pull_request:
    branches:
      - main
    paths:
      - 'frontend/**'

How Filters Combine

  • Multiple branch patterns are OR’d together: main OR feature/** OR release/*
  • Paths and branches are AND’d: branch matches AND path matches
  • paths and paths-ignore together: matched by paths AND not matched by paths-ignore

A Monorepo Pattern

For monorepos, you might want separate workflows for each package:

# .github/workflows/api-tests.yml
name: API Tests
on:
  push:
    paths:
      - 'packages/api/**'
      - 'packages/shared/**'  # Shared code affects API too

# .github/workflows/web-tests.yml
name: Web Tests
on:
  push:
    paths:
      - 'packages/web/**'
      - 'packages/shared/**'

# .github/workflows/docs.yml
name: Documentation
on:
  push:
    paths:
      - 'docs/**'
      - '**/*.md'

Each workflow only runs when its relevant files change. Changes to packages/api/ don’t trigger web tests, and vice versa.

Concurrency Controls

By default, if you push twice in quick succession, you get two workflow runs. Sometimes that’s fine. Sometimes it’s wasteful or even problematic (imagine two deployments racing each other).

name: Deploy

on:
  push:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

With cancel-in-progress: true, a new run cancels any in-progress run in the same concurrency group. The second push wins.

Smarter Concurrency Groups

The group name can include any context you want:

# Per-branch concurrency
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}

# Per-PR concurrency (for PR workflows)
concurrency:
  group: pr-${{ github.event.pull_request.number }}

# Environment-specific concurrency
concurrency:
  group: deploy-${{ github.event.inputs.environment }}

For CI builds, I usually cancel in progress. For deployments, I usually don’t—I’d rather let the first deployment finish than have half-deployed states.

# CI: Cancel in progress
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

# Deployment: Queue, don't cancel
concurrency:
  group: deploy-production
  cancel-in-progress: false

Event Payloads: What Data You Get

Every event type gives you different data in the github.event context. Here’s a quick reference for the common ones.

push

${{ github.event.before }}      # SHA before push
${{ github.event.after }}       # SHA after push
${{ github.event.ref }}         # Full ref (refs/heads/main)
${{ github.event.commits }}     # Array of commit objects
${{ github.event.head_commit }} # Latest commit object
${{ github.event.pusher }}      # Who pushed

pull_request

${{ github.event.action }}              # opened, synchronize, closed, etc.
${{ github.event.pull_request.number }}
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
${{ github.event.pull_request.head.ref }}   # Source branch
${{ github.event.pull_request.base.ref }}   # Target branch
${{ github.event.pull_request.head.sha }}   # Source commit
${{ github.event.pull_request.merged }}     # Boolean
${{ github.event.pull_request.draft }}      # Boolean
${{ github.event.pull_request.user.login }} # PR author

workflow_dispatch

${{ github.event.inputs.<name> }}  # User inputs
${{ github.actor }}                # Who triggered it

repository_dispatch

${{ github.event.action }}            # The event_type from the API call
${{ github.event.client_payload }}    # Whatever JSON you sent

release

${{ github.event.release.tag_name }}
${{ github.event.release.name }}
${{ github.event.release.body }}
${{ github.event.release.prerelease }}
${{ github.event.release.html_url }}

Dumping the Full Payload

When you’re debugging or exploring what’s available, dump everything:

- name: Dump event payload
  run: echo '${{ toJSON(github.event) }}' | jq .

I do this constantly when working with unfamiliar event types. The documentation is good, but seeing real data is better.

Creating Custom Events

You can trigger repository_dispatch from other workflows, effectively creating custom events within your repo.

# Workflow A: Create custom event
- name: Trigger downstream workflow
  uses: actions/github-script@v7
  with:
    script: |
      await github.rest.repos.createDispatchEvent({
        owner: context.repo.owner,
        repo: context.repo.repo,
        event_type: 'build-complete',
        client_payload: {
          sha: context.sha,
          build_number: '${{ github.run_number }}',
          artifacts_url: 'https://...'
        }
      });
# Workflow B: Listen for custom event
on:
  repository_dispatch:
    types:
      - build-complete

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "Build ${{ github.event.client_payload.build_number }} complete"
          echo "SHA: ${{ github.event.client_payload.sha }}"

Cross-Repo Orchestration

You can also trigger workflows in other repositories. This is how you build multi-repo CI/CD systems.

- name: Trigger deployment in infrastructure repo
  uses: actions/github-script@v7
  with:
    github-token: ${{ secrets.CROSS_REPO_TOKEN }}
    script: |
      await github.rest.repos.createDispatchEvent({
        owner: 'my-org',
        repo: 'infrastructure',
        event_type: 'deploy-app',
        client_payload: {
          app: 'my-app',
          version: '${{ github.event.release.tag_name }}',
          source_repo: '${{ github.repository }}'
        }
      });

Note the CROSS_REPO_TOKEN—you need a PAT or GitHub App token with access to the target repository. The default GITHUB_TOKEN only has access to the current repo.

Putting It Together: A Real-World Example

Let me show you a complete example that combines several of these triggers. This is a simplified version of a deployment system I actually use.

name: Deploy Application

on:
  # Manual deployment with environment selection
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - staging
          - production
      version:
        description: 'Version to deploy (leave empty for latest main)'
        required: false
        type: string

  # External trigger (from Slack, etc.)
  repository_dispatch:
    types:
      - deploy

  # Auto-deploy to staging on merge to main
  push:
    branches:
      - main
    paths-ignore:
      - 'docs/**'
      - '*.md'

# Only one deployment per environment at a time
concurrency:
  group: deploy-${{ github.event.inputs.environment || github.event.client_payload.environment || 'staging' }}
  cancel-in-progress: false

jobs:
  determine-params:
    runs-on: ubuntu-latest
    outputs:
      environment: ${{ steps.params.outputs.environment }}
      version: ${{ steps.params.outputs.version }}
    steps:
      - name: Determine deployment parameters
        id: params
        run: |
          # workflow_dispatch
          if [ -n "${{ github.event.inputs.environment }}" ]; then
            echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT
            echo "version=${{ github.event.inputs.version || github.sha }}" >> $GITHUB_OUTPUT
          # repository_dispatch
          elif [ -n "${{ github.event.client_payload.environment }}" ]; then
            echo "environment=${{ github.event.client_payload.environment }}" >> $GITHUB_OUTPUT
            echo "version=${{ github.event.client_payload.version || github.sha }}" >> $GITHUB_OUTPUT
          # push to main
          else
            echo "environment=staging" >> $GITHUB_OUTPUT
            echo "version=${{ github.sha }}" >> $GITHUB_OUTPUT
          fi

  deploy:
    needs: determine-params
    runs-on: ubuntu-latest
    environment: ${{ needs.determine-params.outputs.environment }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ needs.determine-params.outputs.version }}

      - name: Build
        run: npm ci && npm run build

      - name: Deploy
        run: |
          echo "Deploying ${{ needs.determine-params.outputs.version }}"
          echo "To: ${{ needs.determine-params.outputs.environment }}"
          ./deploy.sh ${{ needs.determine-params.outputs.environment }}

      - name: Notify
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const status = '${{ job.status }}' === 'success' ? 'succeeded' : 'failed';
            const env = '${{ needs.determine-params.outputs.environment }}';
            const version = '${{ needs.determine-params.outputs.version }}'.substring(0, 7);

            // Create deployment status (for GitHub's deployments UI)
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: context.payload.deployment?.id,
              state: status === 'succeeded' ? 'success' : 'failure',
              description: `Deployment to ${env} ${status}`,
              environment_url: `https://${env}.example.com`
            });

Three triggers, one workflow. Push to main deploys to staging automatically. Manual or external triggers let you deploy to any environment with version control. Concurrency ensures you never have racing deployments to the same environment.

The Mental Shift

Here’s what I want you to take away: GitHub Actions isn’t just about running code when code changes. It’s about responding to events. And GitHub has a lot of events.

Once you internalize that, you start seeing opportunities everywhere. A release event can trigger a multi-step publishing pipeline. An issue comment can become a command interface. A scheduled job can monitor external systems and open issues when things look wrong. A repository_dispatch can wire GitHub into your broader infrastructure.

The trigger system is the least glamorous part of GitHub Actions—just some YAML at the top of a file. But it’s the part that determines what your automation is actually for.

Start with push and pull_request. That’s fine. But don’t stop there.

Next up in this series: optimization and performance—caching strategies, artifact handling, and making your workflows fast.