Cross-Repo Workflows — Trigger Actions Across Repositories

TL;DR

You can trigger GitHub Actions in one repo from another using repository_dispatch (webhook-style), workflow_dispatch (API call), or workflow_call (reusable workflows). This lets you keep CI/CD logic in a central private repo and trigger it from any of your website repos on push.

Explain Like I'm 12

Imagine you have 5 different homework folders (your website repos), and one smart robot (your central actions repo) that knows how to check and submit homework. Instead of putting a copy of the robot in every folder, you put a tiny walkie-talkie in each folder. When you drop new homework in any folder, the walkie-talkie calls the robot: "Hey, folder 3 has new homework!" The robot wakes up, grabs the homework, checks it, and submits it. One robot, many folders.

How Cross-Repo Triggering Works

Your website repos detect a push and send a signal to the central actions repo. The central repo runs the shared workflow, checking out the website code and deploying it.

Cross-repo workflow architecture: multiple website repos trigger a central actions repo via repository_dispatch or workflow_call, which runs shared CI/CD logic

3 Ways to Trigger Cross-Repo Workflows

ApproachTriggerDirectionBest For
repository_dispatch API webhook event Source repo → target repo Fire-and-forget triggers with custom payloads
workflow_dispatch (API) REST API call Source repo → target repo Triggering with typed inputs, manual or automated
workflow_call Reusable workflow Caller imports from central repo Shared workflow definitions, DRY CI/CD
Info: For private repos, all three approaches require a Personal Access Token (PAT) or GitHub App token with appropriate permissions. The default GITHUB_TOKEN only has access to the current repo.

Approach 1: repository_dispatch

The source repo (website) fires an API event to the target repo (central actions). The target repo listens for that event and runs a workflow.

Step 1: Create a PAT

Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens and create a token with:

  • Repository access: select the central actions repo
  • Permissions: Contents (read), Metadata (read), Actions (write)

Add this token as a secret (DISPATCH_TOKEN) in each website repo under Settings → Secrets → Actions.

Step 2: Listener in the Central Repo

# central-actions-repo/.github/workflows/deploy-website.yml
name: Deploy Website

on:
  repository_dispatch:
    types: [deploy-website]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Show which site triggered this
        run: |
          echo "Repo: ${{ github.event.client_payload.repo }}"
          echo "Branch: ${{ github.event.client_payload.branch }}"
          echo "Commit: ${{ github.event.client_payload.sha }}"

      - name: Checkout the website repo
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.client_payload.repo }}
          ref: ${{ github.event.client_payload.sha }}
          token: ${{ secrets.WEBSITE_REPO_TOKEN }}

      - name: Build and deploy
        run: |
          echo "Building ${{ github.event.client_payload.repo }}..."
          # Your build & deploy commands here
          npm ci
          npm run build
Tip: The client_payload is a free-form JSON object — pass any data you need (repo name, branch, commit SHA, environment). The central workflow uses it to know which site to build.

Step 3: Trigger from Each Website Repo

# website-repo/.github/workflows/trigger-deploy.yml
name: Trigger Central Deploy

on:
  push:
    branches: [main]

jobs:
  trigger:
    runs-on: ubuntu-latest
    steps:
      - name: Dispatch to central actions repo
        run: |
          curl -X POST \
            -H "Accept: application/vnd.github+json" \
            -H "Authorization: Bearer ${{ secrets.DISPATCH_TOKEN }}" \
            https://api.github.com/repos/YOUR-ORG/central-actions/dispatches \
            -d '{
              "event_type": "deploy-website",
              "client_payload": {
                "repo": "${{ github.repository }}",
                "branch": "${{ github.ref_name }}",
                "sha": "${{ github.sha }}",
                "site_name": "my-website-1"
              }
            }'
Warning: Replace YOUR-ORG/central-actions with your actual org/user and repo name. The event_type must match the types: array in the listener workflow.

Approach 2: workflow_dispatch via API

Similar to repository_dispatch, but you trigger a specific workflow file and can pass typed inputs (string, choice, boolean).

Listener in the Central Repo

# central-actions-repo/.github/workflows/deploy-website.yml
name: Deploy Website

on:
  workflow_dispatch:
    inputs:
      repo:
        description: 'Full repo name (org/repo)'
        required: true
        type: string
      ref:
        description: 'Git ref to deploy'
        required: true
        type: string
      environment:
        description: 'Target environment'
        required: true
        default: 'production'
        type: choice
        options:
          - staging
          - production

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Checkout website code
        uses: actions/checkout@v4
        with:
          repository: ${{ inputs.repo }}
          ref: ${{ inputs.ref }}
          token: ${{ secrets.WEBSITE_REPO_TOKEN }}

      - name: Build and deploy
        run: |
          echo "Deploying ${{ inputs.repo }} to ${{ inputs.environment }}"
          npm ci && npm run build

Trigger via API from the Website Repo

# website-repo/.github/workflows/trigger-deploy.yml
name: Trigger Central Deploy

on:
  push:
    branches: [main]

jobs:
  trigger:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger workflow_dispatch via API
        run: |
          curl -X POST \
            -H "Accept: application/vnd.github+json" \
            -H "Authorization: Bearer ${{ secrets.DISPATCH_TOKEN }}" \
            https://api.github.com/repos/YOUR-ORG/central-actions/actions/workflows/deploy-website.yml/dispatches \
            -d '{
              "ref": "main",
              "inputs": {
                "repo": "${{ github.repository }}",
                "ref": "${{ github.sha }}",
                "environment": "production"
              }
            }'
Info: The "ref": "main" at the top level tells GitHub which branch of the central repo to use for the workflow file. The inputs.ref is your custom input for the website commit.

Approach 3: Reusable Workflows (workflow_call)

Instead of the central repo listening for events, each website repo calls a reusable workflow defined in the central repo. The CI/CD logic lives centrally, but execution happens in the caller's context.

Reusable Workflow in the Central Repo

# central-actions-repo/.github/workflows/build-and-deploy.yml
name: Reusable Build & Deploy

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version'
        required: false
        type: string
        default: '22'
      environment:
        description: 'Deploy target'
        required: true
        type: string
    secrets:
      CF_API_TOKEN:
        required: true
      CF_ACCOUNT_ID:
        required: true

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run build

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          projectName: ${{ github.event.repository.name }}
          directory: dist

Caller in Each Website Repo

# website-repo/.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    uses: YOUR-ORG/central-actions/.github/workflows/build-and-deploy.yml@main
    with:
      environment: production
      node-version: '22'
    secrets:
      CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
      CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
Warning: For private repos, the central repo must explicitly allow access. Go to the central repo's Settings → Actions → General → Access and select "Accessible from repositories in the organization" (or specific repos). Without this, callers get a "not found" error.

Which Approach Should You Use?

Factorrepository_dispatchworkflow_dispatch APIworkflow_call
Where logic livesCentral repoCentral repoCentral repo (imported)
Where jobs runCentral repo's runnersCentral repo's runnersCaller repo's runners
Minutes billed toCentral repo ownerCentral repo ownerCaller repo owner
Checkout contextCentral repo (must explicitly checkout website)Central repo (must explicitly checkout website)Caller repo (automatic)
Typed inputsNo (free-form JSON)Yes (string, choice, boolean)Yes (string, number, boolean)
Visible in caller's Actions tabNoNoYes
Private repo accessPAT with repo scopePAT with repo scopeOrg-level access setting
Tip: For your scenario (multiple website repos, one central CI/CD repo), reusable workflows (workflow_call) are the cleanest option — minutes are billed per-site, logs appear in each site's Actions tab, and you get actions/checkout for free. Use repository_dispatch if you need the central repo to orchestrate across multiple sites in one run.

Full Example: Your Multi-Website Setup

Here's the complete setup for your scenario: multiple website repos, one central private repo with shared deploy logic.

Repo Structure

# Your repos:
your-org/central-actions    # Private — contains reusable workflows
your-org/website-alpha      # Private — website 1
your-org/website-beta       # Private — website 2
your-org/website-gamma      # Private — website 3

Central Repo: Reusable Deploy Workflow

# central-actions/.github/workflows/deploy.yml
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      build-command:
        description: 'Build command to run'
        type: string
        default: 'npm run build'
      output-dir:
        description: 'Build output directory'
        type: string
        default: 'dist'
      node-version:
        type: string
        default: '22'
    secrets:
      DEPLOY_TOKEN:
        required: true

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

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: ${{ inputs.build-command }}

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: ${{ inputs.output-dir }}

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: ./deploy

      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "Deploying ${{ github.event.repository.name }}..."
          # Replace with your actual deploy command:
          # e.g., rsync, scp, Cloudflare Wrangler, AWS S3 sync
          ls -la ./deploy

Each Website Repo: 3-Line Caller

# website-alpha/.github/workflows/deploy.yml
name: Deploy Website

on:
  push:
    branches: [main]

jobs:
  deploy:
    uses: your-org/central-actions/.github/workflows/deploy.yml@main
    with:
      build-command: 'npm run build'
      output-dir: 'dist'
    secrets:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

That's it. Every website repo has the same tiny workflow file. All the build/deploy logic lives in central-actions. Update once, all sites get the change.

Info: Pin to a tag (@v1) instead of @main for stability. Create releases in the central repo when you change the workflow, so sites opt-in to updates by bumping the tag.

Token & Permissions Setup

Cross-repo access requires careful token configuration. Here's what each approach needs:

For repository_dispatch / workflow_dispatch

  1. Create a Fine-grained PAT with access to the central repo (Contents: read, Actions: write)
  2. Add the PAT as a secret (DISPATCH_TOKEN) in every website repo
  3. The website repo's workflow uses this token to call the GitHub API

For reusable workflows (workflow_call)

  1. In the central repo: Settings → Actions → General → Access → "Accessible from repositories in the organization"
  2. No PAT needed for the call itself (org-level trust)
  3. Secrets are passed explicitly from the caller — the central workflow never "sees" secrets it isn't given

For checking out cross-repo code (repository_dispatch approach)

  1. The central repo needs a PAT (WEBSITE_REPO_TOKEN) that can read the website repos
  2. Used in actions/checkout with the repository: and token: parameters
Warning: Never use a classic PAT with broad repo scope. Use fine-grained tokens scoped to specific repos with minimum permissions. Rotate tokens regularly. For organizations, prefer GitHub App installation tokens over personal tokens.

Debugging Cross-Repo Triggers

Cross-repo workflows fail silently more often than in-repo ones. Common pitfalls:

ProblemCauseFix
Dispatch returns 404PAT lacks access to target repoCheck fine-grained token has Actions (write) on the target
Dispatch returns 204 but no runevent_type doesn't match types: filterEnsure exact string match (case-sensitive)
Reusable workflow "not found"Central repo access not enabledEnable under Settings → Actions → General → Access
Secrets are empty in reusable workflowSecrets not passed from callerExplicitly pass every secret in the secrets: block
Checkout fails in dispatchToken can't read the source repoAdd token: param to actions/checkout with cross-repo PAT
Wrong code checked outMissing ref: in checkoutAlways pass the exact SHA from client_payload

Verify a Dispatch Worked

# Send a test repository_dispatch
curl -i -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer ghp_YOUR_TOKEN" \
  https://api.github.com/repos/YOUR-ORG/central-actions/dispatches \
  -d '{"event_type":"deploy-website","client_payload":{"repo":"test","sha":"abc123"}}'

# Expected: HTTP 204 No Content (success)
# If 404: token lacks access. If 422: malformed JSON.
# Check if a workflow run was triggered
gh run list --repo YOUR-ORG/central-actions --limit 5
Tip: Add echo statements at the start of your central workflow to log the payload. Check the Actions tab of the central repo (not the website repo) for dispatch-triggered runs.

Test Yourself

What's the difference between repository_dispatch and workflow_call?

repository_dispatch sends an API event to a target repo — the workflow runs in the target repo's context (billed to the target, logs in the target). workflow_call imports a reusable workflow — the workflow runs in the caller's context (billed to the caller, logs in the caller). Dispatch is fire-and-forget; workflow_call is like a function call.

Why can't you use the default GITHUB_TOKEN to trigger workflows in another repo?

The default GITHUB_TOKEN is scoped to the current repository only. It cannot read, write, or trigger actions in other repos. You need a Personal Access Token (PAT) or GitHub App installation token with cross-repo permissions.

You send a repository_dispatch and get HTTP 204, but no workflow runs. What went wrong?

HTTP 204 means the API accepted the request. The most common cause is a mismatch between event_type in the API call and the types: filter in the workflow YAML. The strings must match exactly (case-sensitive). Also check that the workflow file is on the repo's default branch — dispatch workflows must exist on the default branch to trigger.

How do you pass secrets to a reusable workflow?

The reusable workflow declares required secrets in its on: workflow_call: secrets: block. The caller passes them explicitly: secrets: MY_SECRET: ${{ secrets.MY_SECRET }}. Alternatively, use secrets: inherit to pass all of the caller's secrets (convenient but less explicit).

In a multi-website setup, which approach keeps CI minutes billed to each website repo?

Reusable workflows (workflow_call). Since the jobs run in the caller's context, minutes are billed to each website repo individually. With repository_dispatch and workflow_dispatch, all minutes are billed to the central repo.

Interview Questions

Design a CI/CD architecture where 10 microservices share the same build and deploy pipeline, but each service has different environment variables and deploy targets.

Use reusable workflows in a central repo. The shared workflow accepts inputs for the deploy target, build command, and environment name. Each microservice repo has a tiny caller workflow that passes its specific values. Secrets are passed per-environment using GitHub Environments. Pin the reusable workflow to tags for version control. This gives you: single source of truth for pipeline logic, per-service customization via inputs, and clear audit trail via git tags.

What are the security implications of using repository_dispatch with a PAT vs. reusable workflows with org-level access?

PAT risks: the token is stored as a secret in every website repo — if any repo is compromised, the attacker can trigger arbitrary dispatches to the central repo with any payload. PATs are tied to a person, not a system. Org-level access: no token crosses repo boundaries for the call itself — GitHub's built-in trust model handles auth. Secrets are passed explicitly per-call. A compromised repo can only call workflows that are already accessible, not forge payloads. Best practice: use GitHub App installation tokens (auto-rotating, scoped) over PATs.

How would you implement a deployment gate where the central repo runs tests before deploying, and blocks the deploy if tests fail — all triggered from the website repo?

In the reusable workflow, define two jobs: test and deploy. Set deploy.needs: test so deploy only runs if tests pass. Add a GitHub Environment with required reviewers for production deploys. The caller triggers the reusable workflow on push. Tests run automatically. If tests pass, the deploy job waits for manual approval (reviewer notification). If tests fail, the workflow stops and the caller's Actions tab shows the failure. Status checks propagate back to the PR if triggered by pull_request.