Build reliable multi-step automated workflows. Covers orchestration vs. choreography, workflow engines, retry and compensation patterns, and the patterns that make complex automation reliable and observable.
Workflow orchestration patterns are the structured, repeatable ways to coordinate sequences of automated tasks across systems, services, and teams. They matter when a single action requires multiple steps—each potentially idempotent, asynchronous, or failure-prone—and when coordination across boundaries (e.g., dev, ops, security, compliance) is required. This page describes the core patterns used to build reliable, observable, and maintainable workflows, focusing on *how* to design and implement them, not just *what* they are.
The most common pattern: a linear sequence of steps where each step runs after the previous one, but with decisions based on outputs, status, or time.
Use workflow.yml with a steps array, each step defining a run command, depends_on, and condition.
steps:
- name: Validate PR
run: |
git fetch origin
git checkout ${{ github.head_ref }}
npm test -- --coverage
depends_on: []
condition: ${{ github.event_name == 'pull_request' }}
- name: Build Artifact
run: |
docker build -t app:${{ github.sha }} .
docker tag app:${{ github.sha }} registry.example.com/app:${{ github.sha }}
depends_on:
- Validate PR
condition: ${{ success() }}
- name: Deploy to Staging
run: |
kubectl apply -f k8s/staging.yaml
kubectl rollout status deployment/app-staging
depends_on:
- Build Artifact
condition: ${{ github.ref == 'refs/heads/main' }}
The condition field evaluates to a boolean using a simple expression language: success(), failure(), github.event_name == 'push', steps.build_artifact.outputs.image_digest, and contains(github.event.base, 'release/').
Common failure modes:
condition evaluates to false but the step still runs, leading to silent no-ops.depends_on uses step names, but misspellings cause dependency failures that appear as step not found.condition evaluates during *workflow execution*, so steps.<step>.outputs are available only after the step completes, but the condition is checked *before* the step runs.Use condition: ${{ always() }} to ensure a step runs regardless of upstream success or failure.
When multiple independent tasks need to run concurrently, and the workflow must wait for all to complete before continuing.
steps:
- name: Run Tests
run: |
npm test -- --parallel
depends_on: []
- name: Lint Code
run: |
npx eslint src/
npx prettier --check src/
depends_on: []
- name: Build Docs
run: |
mkdocs build
depends_on: []
- name: Deploy to Production
run: |
kubectl apply -f k8s/prod.yaml
depends_on:
- Run Tests
- Lint Code
- Build Docs
condition: ${{ success() }}
The Run Tests, Lint Code, and Build Docs steps run in parallel. The Deploy to Production step waits for all three.
Key insight: fan-out happens implicitly when multiple steps list the same parent via depends_on. Fan-in happens when a step depends on multiple earlier steps.
Sharp edges:
run: true but fails to run due to missing run key or incorrect indentation.depends_on lists step names, but steps must be named exactly. run-tests does not match Run Tests.outputs in each step:- name: Run Tests
run: |
npm test -- --coverage
echo "coverage=87.2" >> $GITHUB_OUTPUT
outputs:
coverage: ${{ steps.run_tests.outputs.coverage }}
Then reference steps.run_tests.outputs.coverage in a later step.
Workflows fail. Errors must be caught, handled, and retried with precision.
Define a retry block per step:
steps:
- name: Deploy to Staging
run: |
kubectl apply -f k8s/staging.yaml
kubectl rollout status deployment/app-staging
retry:
count: 3
delay: 30s
backoff: 2
conditions:
- ${{ failure() }}
- ${{ contains(steps.deploy.outputs.error, 'timeout') }}
- ${{ steps.deploy.outputs.status_code == 503 }}
The retry block specifies:
count: number of retry attempts (default: 1, max: 10).delay: initial delay between attempts in 10s, 5m, 1h format.backoff: factor to multiply delay (e.g., 2 means 30s → 60s → 120s).conditions: a list of conditions that trigger a retry; all must be true.Failure modes:
retry only triggers on failure, but the step may be marked as success even when kubectl rollout status returns 503 error.conditions are checked *after* the step runs, so steps.deploy.outputs.status_code is available only after the step completes.retry does not propagate outputs from the last attempt. Use outputs on each retry attempt and merge them.For complex error handling, define a on_failure block:
on_failure:
run: |
echo "Workflow failed at step: ${{ github.job }}"
echo "Error: ${{ github.event.workflow.failed_step }}"
curl -X POST https://alert.example.com/webhook \
-H "Content-Type: application/json" \
-d '{
"workflow": "${{ github.workflow }}",
"job": "${{ github.job }}",
"error": ${{ github.event.workflow.failed_step }},
"timestamp": "'"$(date -Iseconds)"'"
}'
depends_on: []
This runs after any step fails, regardless of retry.
When workflows consume structured input and produce structured output, enabling reuse and composition.
Define inputs and outputs at the workflow level.
inputs:
environment:
description: 'Target environment to deploy to'
required: true
type: string
default: 'staging'
image_tag:
description: 'Docker image tag to deploy'
required: false
type: string
rollout_delay:
description: 'Delay between rollout steps'
required: false
type: number
default: 30
outputs:
deployment_id:
description: 'Unique ID for this deployment'
value: ${{ steps.deploy.outputs.deployment_id }}
status:
description: 'Deployment status'
value: ${{ steps.deploy.outputs.status }}
The workflow receives inputs from a POST to /api/workflows/{id}/run with a JSON body:
{
"environment": "production",
"image_tag": "v1.2.3",
"rollout_delay": 60
}
The workflow returns outputs in a 200 OK response:
{
"deployment_id": "d-abc123",
"status": "success",
"timestamp": "2023-09-15T10:30:00Z"
}
Key patterns:
inputs are validated at workflow start: missing required inputs cause a 400 Bad Request.outputs are available to parent workflows via steps.<step>.outputs.<key> syntax.env to pass environment variables to all steps:env:
KUBE_CONTEXT: ${{ inputs.environment }}
DEPLOYMENT_DELAY: ${{ inputs.rollout_delay }}
Sharp edges:
inputs.type is string, number, or boolean, but array is not a valid type.inputs are resolved at workflow start, so inputs.environment is static across steps.outputs are resolved only after the step completes, and only the value field is used.When workflows run for hours or days, and must survive restarts, crashes, and partial execution.
Use checkpoint to persist state between runs.
steps:
- name: Fetch Data
run: |
curl -o data.json https://api.example.com/data
echo "last_updated=$(date -Iseconds)" >> $GITHUB_OUTPUT
outputs:
data_file: data.json
last_updated: ${{ steps.fetch_data.outputs.last_updated }}
- name: Process Data
run: |
python process.py --input data.json --output results.csv
echo "processed=true" >> $GITHUB_OUTPUT
depends_on:
- Fetch Data
checkpoint: true
The checkpoint: true flag causes the workflow engine to:
outputs and environment variables.On restart:
steps.process_data.outputs are available even if Fetch Data ran before.Failure modes:
checkpoint: true is not the same as checkpoint: 'true'—the latter is a string.checkpoint: true but not persist its outputs.checkpoint does not automatically restore inputs, so the workflow must load them from a store.When multiple workflows share structure: a common build, test, deploy pipeline.
Define a workflow-template.yml:
name: Deploy App
inputs:
app_name: { type: string, required: true }
image_tag: { type: string, default: 'latest' }
environment: { type: string, default: 'staging' }
steps:
- name: Build Image
run: |
docker build -t ${{ inputs.app_name }}:${{ inputs.image_tag }} .
docker push ${{ inputs.app_name }}:${{ inputs.image_tag }}
outputs:
image: ${{ inputs.app_name }}:${{ inputs.image_tag }}
- name: Deploy to Environment
run: |
kubectl apply -f k8s/${{ inputs.environment }}.yaml
kubectl rollout status deployment/${{ inputs.app_name }}-${{ inputs.environment }}
depends_on:
- Build Image
Then invoke it in another workflow:
jobs:
deploy:
uses: ./.github/workflows/deploy-template.yml
with:
app_name: app
image_tag: v1.0.0
environment: production
Key patterns:
uses resolves to a file path or a GitHub repository.with provides inputs to the template.Sharp edges:
uses must resolve to a valid workflow file; workflow-template.yml is not sufficient—must be workflow-template.yml or workflow.yml.with keys must match exactly; image_tag does not match imageTag.outputs are available in the calling workflow via jobs.deploy.outputs.<key>.This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy