Triggering Workflows Across Repositories

Two ways to trigger a workflow in another repository: a repository dispatch event or a workflow dispatch API call. Token permissions, refs, and cost.

GitHub exposes two ways to start a workflow in another repository: a repository dispatch event, POST /repos/{owner}/{repo}/dispatches, which fires a named event that any workflow on the target's default branch can listen for, and a workflow dispatch, POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches, which names one workflow file, one ref, and typed inputs. Both calls need a credential that reaches across the repository boundary, because GITHUB_TOKEN is limited to the repository whose workflow is running (triggering a workflow from a workflow, checked on 2026-08-13).

This guide covers how to tell the two mechanisms apart, the token permission each endpoint requires, workflow YAML for the calling side and the receiving side, the two failure modes that look like success, and what the caller costs when it waits for the downstream run.

Diagnosis

Both endpoints answer 204 No Content with an empty body. There is no run id in the response, so a dispatch that started nothing and a dispatch that started the wrong copy of a workflow both read as success from the calling job. Start from the response code and the target repository's Actions tab together.

What you seeWhat happenedWhere to look
204, no run appears in the targetNo workflow on the target's default branch declares that event_type under on: repository_dispatch: types:The default branch copy of the workflow file rather than your branch
404The token cannot see the repository at all: a fine-grained token without that repository selected, or an app that is not installed on itThe token's repository access list, or the app installation
403The token authenticates but lacks the permission the endpoint requiresThe permission table below
422Workflow dispatch only: the file at that ref has no workflow_dispatch trigger, or the inputs you sent do not match the ones it declaresThe workflow file as it exists on the dispatched ref
A run appears, on the wrong branchRepository dispatch always runs the default branch copyThe run's branch field

The two mechanisms side by side

Repository dispatchWorkflow dispatch
EndpointPOST /repos/{owner}/{repo}/dispatchesPOST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches
What you addressAn event name, matched by every listening workflowOne workflow file, by filename or id
Branch that runsAlways the target's default branchThe ref you send, which is required
Payloadclient_payload, free-form JSON, up to 10 top-level propertiesinputs, up to 10, declared in the workflow file
Reading itgithub.event.client_payload.*inputs.*, typed as declared
Filteringtypes: list in the on: blockYou picked the file, so no filter
Fine-grained token permissionContents: writeActions: write
FitsFan-out, where several workflows react to one upstream eventA named job with a contract, where you want typed arguments

Endpoint shapes, limits, and permissions come from the repository dispatch reference and the workflow dispatch reference, checked on 2026-08-13. The trigger semantics are in events that trigger workflows, and the input types a receiving workflow can declare are string, choice, boolean, number, and environment (workflow syntax). The workflow dispatch glossary entry carries the trigger definition on its own.

Failure mode one: a token with the wrong permission

The two endpoints want different permissions, which surprises teams who wire the second one after the first already works. A fine-grained personal access token or GitHub App installation token needs Contents: write to post a repository dispatch, and Actions: write to post a workflow dispatch. A classic personal access token needs the repo scope. Granting one and calling the other returns 403 with no hint about which permission is missing, and a token that simply does not list the target repository returns 404 instead, because GitHub does not confirm the existence of repositories a credential cannot see.

Failure mode two: a dispatch that lands on the wrong branch

Repository dispatch has no ref parameter, and the run uses the workflow file on the target's default branch. A payload field named ref changes nothing by itself. It only matters if the receiving workflow reads it and checks it out.

Workflow dispatch takes ref, which makes the branch explicit in the API call, and gh workflow run falls back to the repository's default branch when --ref is omitted. The version of the workflow file that runs is the version on the dispatched ref, so a ref that predates a new input produces a 422 while a ref that predates a bug fix produces a green run of stale logic.

Fix

Choose the mechanism before writing YAML

Reach for a workflow dispatch when the caller knows exactly which downstream job it wants and wants to pass arguments with types. Reach for a repository dispatch when one upstream event should be visible to several receivers and you would rather not maintain a list of workflow filenames in the caller. If the two repositories share build steps rather than a trigger, neither endpoint is the answer: see reusable workflows at scale. The manual path for the same trigger is covered in how to run a workflow manually.

Mint a credential that crosses the boundary

A long-lived personal access token in an organization secret works and ages badly, because it carries a person's access and outlives their team membership. A GitHub App installed on the target repositories is the better shape: actions/create-github-app-token exchanges the app id and private key for an installation token at the start of the run, the token expires an hour later, and its access is the installation rather than a user.

Scope the installation to the target repositories only, grant Actions: write for workflow dispatch or Contents: write for repository dispatch, and grant nothing else.

Pin the ref on purpose

Send ref explicitly on every workflow dispatch, even when the value is main, so the call fails loudly against a branch that no longer exists rather than quietly running something else. For repository dispatch, pass the ref inside client_payload and check it out on the receiving side, since the workflow file itself always comes from the default branch.

Make the run identifiable

Pass a correlation id built from github.run_id and the matrix index, and echo it into run-name on the receiving workflow. That turns an unidentifiable 204 into a run you can find, watch, and attribute.

Where the dispatch job itself runs

The calling job is a token exchange plus a few HTTPS requests, so it wants the smallest runner in the catalog rather than a build shape. A fan-out job that holds a cross-repository token belongs on Linux at 2 vCPU. Each WarpBuild runner runs in its own virtual machine, created on demand and destroyed after the build, and build secrets stay in your repository rather than with the runner provider (security documentation).

Configuration

The calling side mints an installation token, then fans out over a matrix of target repositories. All values in the inputs object go over the wire as JSON strings; the receiving workflow applies the declared type inside the run.

name: release-fanout

on:
  workflow_dispatch:
    inputs:
      version:
        description: Version tag to roll out
        required: true
        type: string

jobs:
  dispatch:
    runs-on: warp-ubuntu-latest-x64-2x
    strategy:
      matrix:
        target:
          - acme/payments-service
          - acme/search-service
          - acme/web-frontend
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.RELEASE_BOT_APP_ID }}
          private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
          owner: acme
          repositories: payments-service,search-service,web-frontend

      - name: Dispatch the downstream release workflow
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TARGET: ${{ matrix.target }}
          VERSION: ${{ inputs.version }}
          CORRELATION: ${{ github.run_id }}-${{ strategy.job-index }}
        run: |
          jq -n \
            --arg version "$VERSION" \
            --arg cid "$CORRELATION" \
            '{ref: "main", inputs: {version: $version, correlation_id: $cid, environment: "production", dry_run: "false"}}' \
          | gh api "repos/$TARGET/actions/workflows/release.yml/dispatches" \
              --method POST --input -

The same job posts a repository dispatch instead when the receivers are a set rather than a list:

      - name: Announce the release to every listener
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TARGET: ${{ matrix.target }}
        run: |
          jq -n \
            --arg version "${{ inputs.version }}" \
            --arg sha "$GITHUB_SHA" \
            '{event_type: "upstream-release", client_payload: {version: $version, sha: $sha, ref: "refs/heads/main"}}' \
          | gh api "repos/$TARGET/dispatches" --method POST --input -

The receiving repository declares typed inputs and accepts both triggers:

name: release
run-name: release ${{ inputs.version }} (${{ inputs.correlation_id }})

on:
  workflow_dispatch:
    inputs:
      version:
        required: true
        type: string
      correlation_id:
        required: true
        type: string
      environment:
        type: choice
        default: staging
        options: [staging, production]
      dry_run:
        type: boolean
        default: true
  repository_dispatch:
    types: [upstream-release]

jobs:
  release:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.client_payload.ref || github.ref }}

      - name: Resolve the version from either trigger
        id: args
        run: |
          echo "version=${{ inputs.version || github.event.client_payload.version }}" >> "$GITHUB_OUTPUT"

      - if: ${{ inputs.dry_run == false }}
        run: ./scripts/release.sh "${{ steps.args.outputs.version }}" "${{ inputs.environment }}"

Two details in that file earn their keep. The inputs context preserves the declared types, so inputs.dry_run == false is a boolean comparison, while github.event.inputs would hand back the string "false". And the checkout falls back to github.ref because a repository dispatch run has an empty inputs context and its own payload shape.

The caller can wait for the downstream run when it needs the result, using the correlation id it sent:

run_id=$(gh run list --repo "$TARGET" --workflow release.yml \
  --json databaseId,displayTitle --limit 20 \
  --jq "map(select(.displayTitle | contains(\"$CORRELATION\"))) | .[0].databaseId")
gh run watch "$run_id" --repo "$TARGET" --exit-status

Cost or Time Model

Waiting is the only expensive part of a cross-repository trigger. The dispatch call itself takes a fraction of a minute; a job that blocks until the downstream run finishes bills for the whole downstream duration on the caller's side as well.

Assumptions

InputValueSource
Target repositories per release8The fan-out above, extended
Releases per month40Your workflow run history
Downstream run duration11 minutesThe target repository's run history
Caller work per dispatch0.6 minutes for checkout, token exchange, and 8 API callsYour run log
Runner rate$0.004 per minute on warp-ubuntu-latest-x64-2xpricing page, checked 2026-08-13

Three designs, same triggers

DesignCaller minutes per releaseCaller minutes per monthCaller cost per month
Fire and forget, no waiting0.624$0.10
One waiting job per target, 8 in a matrix92.83,712$14.85
One job polling all 8 targets in parallel11.6464$1.86

Arithmetic for the middle row: 11 minutes of waiting plus 0.6 minutes of work is 11.6 minutes per target, multiplied by 8 targets is 92.8 minutes per release, multiplied by 40 releases is 3,712 minutes, multiplied by $0.004 is $14.85. The third row runs the same waits inside one job, so the caller bills 11.6 minutes per release rather than 92.8.

Size the waiting job down rather than up

A polling loop sleeps. Running it on a larger label buys nothing and multiplies the bill, and the rates make the gap concrete.

LabelvCPURAMRate per minute
warp-ubuntu-latest-x64-2x28 GB$0.004
warp-ubuntu-latest-x64-4x416 GB$0.008
warp-ubuntu-latest-x64-8x832 GB$0.016

Rates come from the pricing page and the cloud runner catalog, checked on 2026-08-13. The 3,712 waiting minutes from the middle row cost $14.85 a month on the 2x label and $59.39 a month on warp-ubuntu-latest-x64-8x, for a job whose busiest instruction is sleep.

One more limit shapes the design at scale: dispatch calls count against the REST rate limit, which is 5,000 requests per hour for a personal access token (rate limits reference, checked on 2026-08-13). Eight calls per release is nothing; a matrix that dispatches per commit across two hundred repositories is worth counting first.

For where this sits against the rest of the pipeline, see the guide to speeding up GitHub Actions.

FAQ

Can GITHUB_TOKEN trigger a workflow in another repository?

No. GITHUB_TOKEN is scoped to the repository whose workflow is running, so a cross-repository dispatch needs a fine-grained personal access token or a GitHub App installation token that has the target repository in its scope. Inside one repository, GITHUB_TOKEN can fire workflow_dispatch and repository_dispatch, which are the two documented exceptions to the rule that events triggered by GITHUB_TOKEN do not create a new workflow run (triggering a workflow from a workflow, checked on 2026-08-13).

Why did my repository dispatch run on the default branch instead of my branch?

Because that is the documented behavior. A repository_dispatch run uses the workflow file on the target repository's default branch, and the endpoint takes no ref parameter (events that trigger workflows, checked on 2026-08-13). Put the ref in client_payload and check it out on the receiving side, or switch to a workflow dispatch, whose ref field is required.

How do I get the run id of the workflow I triggered?

Neither endpoint returns one. Both answer 204 No Content with an empty body, so pass a correlation id as an input, echo it into run-name on the receiving workflow, and poll gh run list for the run whose display title carries that id. The snippet in the configuration section above does exactly that before handing off to gh run watch.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.