Back to Software Engineering & Delivery Mastery Series GitHub Actions Bootcamp

Module 2: Workflow Triggers and Events

June 2, 2026 Wasil Zafar 30 min read

Master all GitHub Actions event types — push, pull_request, issues, schedule, workflow_dispatch, repository_dispatch — with branch/path/tag filtering, fork security, and cross-workflow triggers.

Table of Contents

  1. Common Repository Events
  2. Event Activity Types & Filters
  3. Branch, Tag & Path Filters
  4. Pull Request Security
  5. Manual Triggers (workflow_dispatch)
  6. Scheduled Workflows (cron)
  7. Repository Dispatch & External Triggers
  8. Fork PR Approval Settings
  9. Cross-Workflow Triggers
  10. Exercises

Common Repository Events

GitHub Actions responds to events — things that happen in your repository. Every workflow starts with an on: key that specifies which events trigger execution. Understanding the full event catalog is essential for building precise, efficient CI/CD pipelines.

GitHub Actions Event Taxonomy
flowchart TD
    A[Workflow Triggers] --> B[Repository Events]
    A --> C[Scheduled Events]
    A --> D[Manual Events]
    A --> E[Cross-Workflow Events]

    B --> B1[push]
    B --> B2[pull_request]
    B --> B3[issues]
    B --> B4[release]
    B --> B5[create / delete]
    B --> B6[fork / watch / star]

    C --> C1["schedule (cron)"]

    D --> D1[workflow_dispatch]
    D --> D2[repository_dispatch]

    E --> E1[workflow_run]
    E --> E2[workflow_call]

    style A fill:#132440,color:#fff
    style B fill:#3B9797,color:#fff
    style C fill:#16476A,color:#fff
    style D fill:#BF092F,color:#fff
    style E fill:#3B9797,color:#fff
                            

Push Events

The push event fires whenever commits are pushed to a branch or tag. This is the most common trigger for CI pipelines — run tests every time code changes.

# .github/workflows/ci-on-push.yml
name: CI on Push

on:
  push:
    branches:
      - main
      - develop
      - 'release/**'
    tags:
      - 'v*'

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

The push event provides rich context through github.event: the list of commits pushed, the before/after SHAs, the pusher's identity, and whether it was a force push.

Pull Request Events

The pull_request event fires for PR lifecycle actions. By default it triggers on opened, synchronize (new commits pushed), and reopened.

# .github/workflows/pr-checks.yml
name: PR Checks

on:
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Issues and Comments

Issue-related events enable powerful automation — auto-labeling, triage bots, stale issue management, and slash command handlers.

# .github/workflows/issue-triage.yml
name: Issue Triage

on:
  issues:
    types: [opened, labeled]
  issue_comment:
    types: [created]

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - name: Auto-label based on title
        if: github.event_name == 'issues' && github.event.action == 'opened'
        uses: actions/github-script@v7
        with:
          script: |
            const title = context.payload.issue.title.toLowerCase();
            const labels = [];
            if (title.includes('bug')) labels.push('bug');
            if (title.includes('feature')) labels.push('enhancement');
            if (title.includes('docs')) labels.push('documentation');
            if (labels.length > 0) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                labels: labels
              });
            }

Release, Create, Delete, and Other Events

Event Reference Table
Event Fires When Common Use Case
pushCommits pushed to branch/tagCI testing, deployments
pull_requestPR opened/updated/mergedPR checks, previews
issuesIssue created/edited/labeledTriage automation
issue_commentComment on issue or PRSlash commands, bot replies
releaseRelease published/createdPackage publishing, deployment
createBranch or tag createdBranch setup automation
deleteBranch or tag deletedCleanup resources
forkRepository forkedTracking/notifications
watchRepository starredCommunity metrics
deploymentDeployment requested via APICustom deploy orchestration
discussionDiscussion created/editedCommunity management
labelLabel created/edited/deletedRepository maintenance
# .github/workflows/publish-on-release.yml
name: Publish Package on Release

on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://npm.pkg.github.com'
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Event Activity Types and Filters

Most events have multiple activity types — sub-events that describe what specifically happened. By default, workflows trigger on a subset of activity types. The types: keyword lets you be precise about which activities matter.

Key Insight: Without types:, pull_request triggers on opened, synchronize, and reopened only. If you need to react to PR reviews, labels, or assignments, you must explicitly list those activity types.

Pull Request Activity Types

# .github/workflows/pr-complete-lifecycle.yml
name: PR Lifecycle

on:
  pull_request:
    types:
      - opened          # PR created
      - synchronize     # New commits pushed to PR branch
      - reopened        # Previously closed PR reopened
      - ready_for_review # Draft PR marked as ready
      - labeled         # Label added to PR
      - unlabeled       # Label removed from PR
      - assigned        # Reviewer assigned
      - review_requested # Review requested from team/person
      - closed          # PR closed (merged or not)

jobs:
  on-open-or-sync:
    if: github.event.action == 'opened' || github.event.action == 'synchronize'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  on-ready-for-review:
    if: github.event.action == 'ready_for_review'
    runs-on: ubuntu-latest
    steps:
      - name: Notify team
        run: echo "PR is ready for review - ${{ github.event.pull_request.title }}"

  on-labeled-deploy:
    if: github.event.action == 'labeled' && github.event.label.name == 'deploy-preview'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Deploying preview for PR #${{ github.event.pull_request.number }}"

Issues Activity Types

# .github/workflows/issue-automation.yml
name: Issue Automation

on:
  issues:
    types:
      - opened       # New issue created
      - edited       # Issue title/body changed
      - deleted      # Issue deleted
      - transferred  # Issue moved to another repo
      - pinned       # Issue pinned
      - unpinned     # Issue unpinned
      - closed       # Issue closed
      - reopened     # Issue reopened
      - assigned     # Assignee added
      - unassigned   # Assignee removed
      - labeled      # Label added
      - unlabeled    # Label removed
      - milestoned   # Added to milestone

jobs:
  welcome-new-contributor:
    if: github.event.action == 'opened'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const creator = context.payload.issue.user.login;
            const { data: issues } = await github.rest.issues.listForRepo({
              owner: context.repo.owner,
              repo: context.repo.repo,
              creator: creator,
              state: 'all'
            });
            if (issues.length === 1) {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: `Welcome @${creator}! Thanks for your first issue. A maintainer will review it soon.`
              });
            }

Using Event Filters (Branches, Tags, Paths)

Event filters let you narrow when a workflow runs. Instead of triggering on every push to any branch, you can target specific branches, tags, or file paths. This eliminates wasted CI minutes and reduces notification noise.

Branch Filters

# .github/workflows/branch-filtering.yml
name: Branch-Filtered CI

on:
  push:
    # Include patterns — workflow runs ONLY for matching branches
    branches:
      - main                    # Exact match
      - develop                 # Exact match
      - 'release/**'           # Glob: release/1.0, release/2.0/rc1, etc.
      - 'feature/*'            # Glob: feature/login (but NOT feature/auth/oauth)
      - '!feature/experimental' # Negation: exclude this specific branch

  pull_request:
    # branches-ignore — workflow runs for ALL branches EXCEPT these
    branches-ignore:
      - 'dependabot/**'        # Skip Dependabot PRs
      - 'docs/**'              # Skip documentation branches

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Running on branch ${{ github.ref_name }}"
Important: You cannot use both branches: and branches-ignore: for the same event. Choose one approach. Use branches: (allowlist) when you want to target specific branches. Use branches-ignore: (denylist) when you want to exclude a few branches from an otherwise open trigger.

Tag Filters

# .github/workflows/release-on-tag.yml
name: Release on Tag

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'      # Matches v1.0.0, v2.13.7, etc.
      - 'v[0-9]+.[0-9]+.[0-9]+-rc*'   # Matches v1.0.0-rc1, v2.0.0-rc.3

    # tags-ignore works the same way
    # tags-ignore:
    #   - 'v*-alpha*'   # Skip alpha pre-releases

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Extract version from tag
        id: version
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
      - name: Build release
        run: |
          echo "Building version ${{ steps.version.outputs.version }}"
          npm ci
          npm run build

Path Filters

Path filters are one of the most impactful optimizations. In a monorepo with frontend, backend, and infrastructure code, you can run frontend CI only when frontend files change.

# .github/workflows/monorepo-ci.yml
name: Monorepo CI

on:
  push:
    branches: [main]
    paths:
      - 'src/frontend/**'      # Only frontend source files
      - 'package.json'         # Dependency changes
      - 'package-lock.json'
      - '.github/workflows/monorepo-ci.yml'  # This workflow file itself

  pull_request:
    branches: [main]
    paths-ignore:
      - '**.md'                # Skip documentation-only changes
      - 'docs/**'             # Skip docs folder
      - '.github/ISSUE_TEMPLATE/**'
      - 'LICENSE'
      - '.gitignore'

jobs:
  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd src/frontend && npm ci && npm test
Monorepo Strategy: Create separate workflow files per service — ci-frontend.yml, ci-backend.yml, ci-infra.yml — each with targeted path filters. This gives maximum parallelism while ensuring each service only rebuilds when its files change.

Combining Filters

When you combine branch and path filters, both must match for the workflow to trigger. This is an AND relationship.

# Only triggers when:
# 1. Push is to main or develop AND
# 2. Files in src/ or tests/ are changed
on:
  push:
    branches: [main, develop]
    paths: ['src/**', 'tests/**']

Pull Request Events and Security Considerations

Pull requests from forks present a fundamental security challenge. A fork contributor can modify the workflow file in their PR, potentially exfiltrating secrets or running malicious code. GitHub's security model handles this through careful permission boundaries.

Fork PR Security Model
flowchart TD
    A[PR from Fork] --> B{Which event?}
    B -->|pull_request| C[Runs in fork context]
    B -->|pull_request_target| D[Runs in base repo context]

    C --> C1[Read-only GITHUB_TOKEN]
    C --> C2[NO access to secrets]
    C --> C3[Cannot write to base repo]
    C --> C4[Safe by default]

    D --> D1[Full GITHUB_TOKEN permissions]
    D --> D2[Access to ALL secrets]
    D --> D3[Can write to base repo]
    D --> D4[DANGEROUS if checkout PR code]

    style A fill:#132440,color:#fff
    style C fill:#3B9797,color:#fff
    style D fill:#BF092F,color:#fff
    style C4 fill:#3B9797,color:#fff
    style D4 fill:#BF092F,color:#fff
                            
Critical Security Warning: The pull_request_target event runs in the context of the base repository with full access to secrets. If you checkout the PR's head code (ref: ${{ github.event.pull_request.head.sha }}) and then execute it, an attacker can exfiltrate all your repository secrets. This is one of the most common GitHub Actions security vulnerabilities.

Safe Pattern: pull_request (Default)

# .github/workflows/pr-safe.yml — Safe for fork PRs
name: PR Checks (Safe)

# pull_request runs in fork context — no secrets available
on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4  # Checks out the merge commit (safe)
      - run: npm ci
      - run: npm test
      # This works fine — no secrets needed for basic CI

Dangerous Pattern: pull_request_target

# .github/workflows/pr-label.yml — Safe use of pull_request_target
name: Label PR (Safe)

# pull_request_target runs in BASE repo context — has secrets
on:
  pull_request_target:
    types: [opened]

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      # SAFE: We do NOT checkout PR code — only use event metadata
      - name: Add size label
        uses: actions/github-script@v7
        with:
          script: |
            const { additions, deletions } = context.payload.pull_request;
            const size = additions + deletions;
            let label = 'size/S';
            if (size > 100) label = 'size/M';
            if (size > 500) label = 'size/L';
            if (size > 1000) label = 'size/XL';
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: [label]
            });
# .github/workflows/pr-dangerous.yml — DANGEROUS anti-pattern
name: PR Build (DANGEROUS - DO NOT COPY)

on:
  pull_request_target:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # DANGEROUS: Checking out PR code in base context
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Attacker's code!

      # DANGEROUS: Running attacker's code with access to secrets
      - run: npm ci   # package.json could have malicious postinstall scripts
      - run: npm test # Tests could exfiltrate ${{ secrets.DEPLOY_KEY }}
        env:
          API_KEY: ${{ secrets.API_KEY }}  # Directly exposed to attacker

Safe Pattern When You Need Both

# .github/workflows/pr-comment-results.yml
# Pattern: Run tests in safe context, then report in privileged context
name: PR Test and Report

on:
  # Safe: tests run without secrets
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test -- --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/lcov.info

---
# Separate workflow triggered after the above completes
# .github/workflows/pr-report.yml
name: Report Coverage

on:
  workflow_run:
    workflows: ["PR Test and Report"]
    types: [completed]

jobs:
  report:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: coverage-report
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - name: Comment coverage on PR
        uses: actions/github-script@v7
        with:
          script: |
            // Post coverage results as PR comment (has write access)
            const fs = require('fs');
            const coverage = fs.readFileSync('coverage/lcov.info', 'utf8');
            // Parse and comment...

Manual Triggers (workflow_dispatch)

The workflow_dispatch event lets you trigger workflows manually from the GitHub UI, the REST API, or the gh CLI. This is essential for deployment workflows, ad-hoc tasks, and operations that shouldn't run automatically.

# .github/workflows/deploy.yml
name: Deploy Application

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target deployment environment'
        required: true
        type: choice
        options:
          - development
          - staging
          - production
        default: staging

      version:
        description: 'Version to deploy (e.g., 1.2.3 or latest)'
        required: true
        type: string
        default: 'latest'

      dry_run:
        description: 'Perform a dry run without actual deployment'
        required: false
        type: boolean
        default: false

      log_level:
        description: 'Logging verbosity'
        required: false
        type: choice
        options:
          - info
          - debug
          - trace
        default: info

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.version == 'latest' && 'main' || format('v{0}', inputs.version) }}

      - name: Validate inputs
        run: |
          echo "Environment: ${{ inputs.environment }}"
          echo "Version: ${{ inputs.version }}"
          echo "Dry Run: ${{ inputs.dry_run }}"
          echo "Log Level: ${{ inputs.log_level }}"

      - name: Deploy
        if: inputs.dry_run == false
        run: |
          ./scripts/deploy.sh \
            --env "${{ inputs.environment }}" \
            --version "${{ inputs.version }}" \
            --log-level "${{ inputs.log_level }}"

      - name: Dry run
        if: inputs.dry_run == true
        run: |
          echo "DRY RUN — would deploy ${{ inputs.version }} to ${{ inputs.environment }}"
          ./scripts/deploy.sh --env "${{ inputs.environment }}" --version "${{ inputs.version }}" --dry-run
Input Types Available:
  • string — Free-text input field
  • choice — Dropdown with predefined options
  • boolean — Checkbox (true/false)
  • environment — Dropdown of configured GitHub Environments
All inputs are available as ${{ inputs.name }} in the workflow. They're always strings at runtime (even booleans become "true"/"false").

Triggering via CLI and API

# Trigger via GitHub CLI
gh workflow run deploy.yml \
  -f environment=staging \
  -f version=1.2.3 \
  -f dry_run=false \
  -f log_level=debug

# Trigger via REST API
curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github.v3+json" \
  "https://api.github.com/repos/OWNER/REPO/actions/workflows/deploy.yml/dispatches" \
  -d '{
    "ref": "main",
    "inputs": {
      "environment": "production",
      "version": "2.0.0",
      "dry_run": "false",
      "log_level": "info"
    }
  }'

Scheduled Workflows (cron)

The schedule event uses POSIX cron syntax to run workflows at specified times. This is ideal for nightly builds, periodic dependency updates, health checks, and report generation.

# .github/workflows/nightly.yml
name: Nightly Tasks

on:
  schedule:
    # ┌───────────── minute (0-59)
    # │ ┌───────────── hour (0-23)
    # │ │ ┌───────────── day of month (1-31)
    # │ │ │ ┌───────────── month (1-12)
    # │ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
    # │ │ │ │ │
    - cron: '0 6 * * 1-5'    # 6:00 AM UTC, Monday through Friday
    - cron: '0 0 * * 0'      # Midnight UTC every Sunday (weekly)

jobs:
  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check for outdated dependencies
        run: |
          npm outdated --json > outdated.json || true
          if [ -s outdated.json ]; then
            echo "::warning::Outdated dependencies found"
            cat outdated.json
          fi

  stale-issues:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    steps:
      - uses: actions/stale@v9
        with:
          days-before-stale: 30
          days-before-close: 7
          stale-issue-label: 'stale'
          stale-pr-label: 'stale'
          stale-issue-message: 'This issue has been inactive for 30 days and will be closed in 7 days unless there is new activity.'

Cron Syntax Quick Reference

Common Cron Schedules
ExpressionScheduleUse Case
0 * * * *Every hourHealth checks
0 6 * * *Daily at 6 AM UTCNightly builds
0 6 * * 1-5Weekdays at 6 AM UTCBusiness-hours CI
0 0 * * 0Weekly (Sunday midnight)Dependency audits
0 0 1 * *Monthly (1st at midnight)License checks
*/15 * * * *Every 15 minutesMonitoring (use sparingly)
0 9 * * 1Monday at 9 AM UTCWeekly reports
Schedule Caveats:
  • All times are UTC — There's no timezone setting. Convert your local time to UTC.
  • Not guaranteed exact — During high-load periods, scheduled workflows may be delayed by up to 15-30 minutes.
  • Minimum interval: 5 minutes — GitHub ignores cron expressions that run more frequently.
  • Disabled after 60 days of inactivity — If no repo activity occurs for 60 days, scheduled workflows are automatically disabled. A push or manual trigger re-enables them.
  • Runs on default branch only — Scheduled workflows always use the version from the default branch (usually main).

Repository Dispatch and External Triggers

The repository_dispatch event enables external systems to trigger workflows via the GitHub REST API. This bridges GitHub Actions with external tools — monitoring systems, CMS webhooks, custom dashboards, or other CI/CD platforms.

# .github/workflows/external-trigger.yml
name: External Deployment Trigger

on:
  repository_dispatch:
    types:
      - deploy-request
      - rollback-request
      - cache-invalidation

jobs:
  handle-deploy:
    if: github.event.action == 'deploy-request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy with payload data
        run: |
          echo "Deploying version: ${{ github.event.client_payload.version }}"
          echo "Requested by: ${{ github.event.client_payload.requested_by }}"
          echo "Target: ${{ github.event.client_payload.environment }}"
          ./scripts/deploy.sh \
            --version "${{ github.event.client_payload.version }}" \
            --env "${{ github.event.client_payload.environment }}"

  handle-rollback:
    if: github.event.action == 'rollback-request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Rollback
        run: |
          echo "Rolling back to: ${{ github.event.client_payload.target_version }}"
          echo "Reason: ${{ github.event.client_payload.reason }}"

Triggering Repository Dispatch from External Systems

# Trigger deployment via API
curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github.v3+json" \
  "https://api.github.com/repos/OWNER/REPO/dispatches" \
  -d '{
    "event_type": "deploy-request",
    "client_payload": {
      "version": "2.1.0",
      "environment": "production",
      "requested_by": "monitoring-bot",
      "timestamp": "2026-06-02T10:30:00Z",
      "commit_sha": "abc123def456"
    }
  }'
// Trigger from a Node.js application (e.g., Slack bot, monitoring service)
const response = await fetch(
  'https://api.github.com/repos/OWNER/REPO/dispatches',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      event_type: 'deploy-request',
      client_payload: {
        version: '2.1.0',
        environment: 'staging',
        requested_by: 'slack-bot',
        channel: '#deployments'
      }
    })
  }
);

if (response.status === 204) {
  console.log('Dispatch event sent successfully');
}
Use Cases for Repository Dispatch:
  • ChatOps — Trigger deployments from Slack/Teams commands
  • External monitoring — Auto-rollback when an alert fires
  • Cross-repo orchestration — One repo's release triggers another repo's deploy
  • CMS webhooks — Rebuild static site when content changes in headless CMS
  • Scheduled from external scheduler — More control than built-in cron

Workflow Run Approval for Forked Pull Requests

For public repositories, GitHub requires manual approval before running workflows on PRs from first-time contributors. This prevents abuse of your CI minutes and protects against crypto-mining attacks.

Approval Settings

Navigate to Settings → Actions → General → Fork pull request workflows to configure:

Fork PR Approval Levels
SettingWho Needs ApprovalRisk Level
Require approval for first-time contributorsOnly new contributors who haven't had PRs mergedLow (recommended for active projects)
Require approval for first-time contributors who are new to GitHubOnly accounts created recentlyLowest (most permissive)
Require approval for all outside collaboratorsEveryone without write accessHighest (most secure, most friction)

When approval is required, maintainers see a yellow "Approve and run" button on the PR's checks section. The workflow remains in a "Waiting for approval" state until a maintainer approves it.

Best Practice: For open-source projects, use "Require approval for first-time contributors." This balances security with contributor friction — established contributors can iterate on PRs without waiting for approval each time, while new accounts are gated.

Triggering Workflows from Other Workflows

GitHub Actions provides two mechanisms for workflow chaining: workflow_run (event-based, loosely coupled) and workflow_call (direct invocation, tightly coupled like a function call).

workflow_run — Event-Based Chaining

The workflow_run event triggers when another workflow completes, succeeds, or is requested. This enables sequential workflows without tight coupling.

# .github/workflows/deploy-after-ci.yml
name: Deploy After CI

on:
  workflow_run:
    workflows: ["CI Pipeline"]     # Name of the triggering workflow
    types: [completed]              # completed, requested
    branches: [main]                # Optional: filter by branch

jobs:
  deploy:
    # Only deploy if CI succeeded
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: |
          echo "CI passed on main — deploying"
          echo "Triggered by run: ${{ github.event.workflow_run.id }}"
          echo "Head SHA: ${{ github.event.workflow_run.head_sha }}"
          ./scripts/deploy.sh --env production

  notify-failure:
    if: github.event.workflow_run.conclusion == 'failure'
    runs-on: ubuntu-latest
    steps:
      - name: Notify team of CI failure
        run: |
          echo "::error::CI Pipeline failed on main"
          # Send Slack notification, create issue, etc.

workflow_call — Reusable Workflows

The workflow_call event turns a workflow into a reusable component that other workflows can invoke directly — like calling a function. The caller passes inputs and receives outputs.

# .github/workflows/reusable-deploy.yml — The reusable workflow
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      version:
        required: true
        type: string
      notify:
        required: false
        type: boolean
        default: true
    secrets:
      DEPLOY_KEY:
        required: true
      SLACK_WEBHOOK:
        required: false
    outputs:
      deploy_url:
        description: "The URL of the deployed application"
        value: ${{ jobs.deploy.outputs.url }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        id: deploy
        run: |
          echo "Deploying ${{ inputs.version }} to ${{ inputs.environment }}"
          url="https://${{ inputs.environment }}.example.com"
          echo "url=$url" >> "$GITHUB_OUTPUT"
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

      - name: Notify
        if: inputs.notify
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -d '{"text":"Deployed ${{ inputs.version }} to ${{ inputs.environment }}"}'
# .github/workflows/release-pipeline.yml — The caller workflow
name: Release Pipeline

on:
  push:
    tags: ['v*']

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

  deploy-staging:
    needs: test
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      version: ${{ github.ref_name }}
      notify: true
    secrets:
      DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      version: ${{ github.ref_name }}
      notify: true
    secrets:
      DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
      SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
Workflow Chaining Patterns
flowchart LR
    subgraph "workflow_run (Loose Coupling)"
        A1[CI Workflow] -->|completes| B1[Deploy Workflow]
        A1 -->|completes| C1[Notify Workflow]
    end

    subgraph "workflow_call (Tight Coupling)"
        A2[Release Pipeline] -->|calls| B2[Reusable: Test]
        A2 -->|calls| C2[Reusable: Deploy]
        A2 -->|calls| D2[Reusable: Notify]
    end

    style A1 fill:#3B9797,color:#fff
    style A2 fill:#3B9797,color:#fff
    style B1 fill:#16476A,color:#fff
    style C1 fill:#16476A,color:#fff
    style B2 fill:#16476A,color:#fff
    style C2 fill:#16476A,color:#fff
    style D2 fill:#16476A,color:#fff
                            
When to Use Each:
  • workflow_run — When workflows are independent and you want to chain on completion (e.g., deploy after CI passes, comment on PR after tests finish)
  • workflow_call — When you want to reuse workflow logic like a function (e.g., standardized deploy process used by 10 repos, shared test matrix)

Exercises

Exercise 1: Path-Filtered Monorepo CI

Create a repository with frontend/ and backend/ directories. Write two separate workflow files — one for each service — using paths: filters so that changing frontend/src/app.js only triggers the frontend CI, and changing backend/server.py only triggers the backend CI. Verify by making isolated commits to each directory.

Exercise 2: Scheduled Security Audit

Build a scheduled workflow that runs every Monday at 9 AM UTC. It should: (1) run npm audit and save the output, (2) check for outdated dependencies with npm outdated, and (3) create a GitHub Issue if any high/critical vulnerabilities are found. Use actions/github-script to create the issue programmatically.

Exercise 3: Manual Deploy with Approval Gate

Create a workflow_dispatch workflow with inputs for environment (choice: staging/production) and version (string). Configure a GitHub Environment called "production" with required reviewers. The workflow should deploy to staging immediately but require manual approval before deploying to production. Trigger it from the gh CLI.

Exercise 4: Cross-Repo Dispatch Chain

Set up two repositories. In Repo A, create a workflow that on successful completion of tests, sends a repository_dispatch event to Repo B with the commit SHA and version in the client_payload. In Repo B, create a workflow that listens for that dispatch event and uses the payload data to deploy. Test the full chain by pushing to Repo A and verifying Repo B's workflow triggers.

Next in the Bootcamp

In Module 3: Jobs, Steps, and Workflow Structure, we'll master job dependencies, conditional execution, matrix strategies, reusable steps, and how to structure complex multi-job workflows for maximum parallelism and clarity.