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 textchoice: Dropdown with predefined optionsboolean: Checkbox (becomes the string ’true’ or ‘false’)number: Numeric inputenvironment: 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:
- User types
/deploy production v2.3.1in Slack - Slack sends the command to your serverless function (Lambda, Cloudflare Worker, whatever)
- Your function validates the user has permission, then calls repository_dispatch
- GitHub Actions runs the deployment
- 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:
- Separation of concerns: Different teams might own different workflows
- Conditional execution: The second workflow can have different trigger conditions
- Secrets and permissions: You can grant different permissions to different workflows
- 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 titlegithub.event.release.body: The release notesgithub.event.release.prerelease: Boolean for pre-releasesgithub.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:
mainORfeature/**ORrelease/* - Paths and branches are AND’d: branch matches AND path matches
pathsandpaths-ignoretogether: matched bypathsAND not matched bypaths-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.