Chaining workflows with workflow_run Jump to heading
A single workflow file that builds, tests, scans, deploys and posts results grows unreadable, and it forces every step to share one trigger and one set of permissions. Splitting it into separate workflows is cleaner, but then something has to start the second one when the first finishes. workflow_run does that: it triggers a workflow when another named workflow completes, gives it the triggering run’s details, and lets it download that run’s artefacts. It is also the standard way to do privileged work after unprivileged CI on fork pull requests. It has quirks — it runs on the default branch’s workflow definition, its context describes the other run rather than a push, and conclusions must be checked explicitly. This page covers them, within CI/CD pipeline trigger mapping.
When to use this approach Jump to heading
- A follow-up job — deploy, publish, comment, notify — should run only after CI succeeds.
- Different stages need different permissions or secrets, and you want them in separate workflows.
- Fork pull requests need a privileged follow-up, as in running CI for fork pull requests safely.
- A tag or commit created by one workflow should start another, which the default token cannot do directly.
Step 1 — Trigger on another workflow’s completion Jump to heading
Name the upstream workflow by its name: field, not its file name, and filter on completion. Then check the conclusion yourself: completed includes failures and cancellations.
# .github/workflows/deploy-staging.yml
on:
workflow_run:
workflows: ["CI"] # the upstream workflow's name:
types: [completed]
branches: [main] # only runs of CI on main
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: "${{ github.event.workflow_run.head_sha }}" }
- run: ./scripts/deploy.sh staging Checking out head_sha rather than the branch matters: by the time the chained workflow starts, main may have moved, and you want to deploy the commit that was actually tested.
Step 2 — Pass data through artefacts Jump to heading
The chained workflow receives the upstream run’s ID, which it uses to download that run’s artefacts. That is the clean way to pass build outputs, test reports or version numbers between stages.
# In CI: upload what the next stage needs
- run: make build && echo "${GITHUB_SHA}" > dist/SOURCE_SHA
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ } # In the chained workflow: download from the triggering run
- uses: actions/download-artifact@v4
with:
name: dist
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- run: test "$(cat dist/SOURCE_SHA)" = "${{ github.event.workflow_run.head_sha }}" Deploying the artefact CI built, rather than rebuilding, guarantees that what ships is what was tested.
Step 3 — Understand which definition and permissions apply Jump to heading
A workflow_run workflow always runs the version of its file on the default branch, with the base repository’s permissions and secrets — even when the upstream run was for a fork pull request. That is what makes it useful for privileged follow-ups, and what makes it dangerous if it executes anything from the upstream run.
# Guard privileged chains so they only act on runs from this repository
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository Step 4 — Avoid accidental loops and fan-out Jump to heading
A chained workflow can itself be an upstream for another chain. Keep chains short and acyclic: CI → deploy-staging → smoke-test is fine; a smoke-test workflow that re-triggers CI is a loop. GitHub limits chains to a few levels deep, but hitting that limit is a sign the design is wrong.
# List workflow_run relationships in a repository to spot cycles
grep -l 'workflow_run' .github/workflows/*.yml | while read -r f; do
printf '%s <- %s\n' "$(sed -n 's/^name: *//p' "$f")" "$(sed -n '/workflow_run/,/types/p' "$f" | sed -n 's/.*workflows: *//p')"
done Step 5 — Debug a chain that did not start Jump to heading
The most common reasons a chained workflow does not run are mismatched names, the file not being on the default branch, and branch filters that do not match.
gh run list --workflow deploy-staging.yml --limit 5 --json event,conclusion,headSha
grep -n '^name:' .github/workflows/ci.yml # must equal the value in workflows: [...] Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can a chained workflow report status back on the pull request? Jump to heading
Yes, through the API: create a check run or commit status on head_sha with a token that has the right permission. Without that, the chained workflow’s result is visible only in the Actions tab, not on the pull request.
Is workflow_run better than needs within one workflow? Jump to heading
For stages with the same trigger and permissions, needs between jobs in one workflow is simpler and shows as one run. Use workflow_run when stages need different triggers, permissions or definitions.
What about GitLab? Jump to heading
GitLab uses stages and needs within one pipeline, and multi-project or parent-child pipelines with trigger: for chaining. The privilege concerns are similar: child pipelines inherit the parent’s context, so be careful about running untrusted code in them.
Related Jump to heading
- CI/CD Pipeline Trigger Mapping — the parent topic.
- Triggering Workflows on Tags and Releases — when automation-created tags need a chain.
- Posting CI Results as PR Comments Without Spam — a common chained task.
- Verifying Attestations Before Deploy — a check to add before a chained deploy.