Module 7: Monitoring, Notifications, and Advanced Patterns
6. Composite Actions
Overview
In the previous capsule you learned about reusable workflows — which reuse complete workflows (multiple jobs). But there's a finer level of granularity: what happens when what you want to reuse isn't an entire workflow but a set of steps that repeats inside several jobs?
A concrete example: in your pipeline, 4 of the 5 jobs start with the same 3 steps: checkout, setup Python 3.12, and install dependencies. They're the same 12 lines of YAML copied 4 times. If you change the Python version, you have to update 4 places in the same file. If you add caching, that's 4 modifications.
Composite actions encapsulate several steps into a single reusable action. You define an action.yml with inputs, outputs, and a sequence of steps. Then you use it with uses: in any job, as if it were a marketplace action. The difference from reusable workflows: composite actions run inside the caller's job, they don't create their own jobs.
Connection with the final pipeline: In the capstone pipeline (Module 8), composite actions encapsulate repetitive operations like the Python setup + cache + install, reducing the main pipeline's YAML to high-level steps.
The structure of action.yml
The anatomy of a composite action
# .github/actions/setup-python-project/action.yml
name: "Setup Python Project"
description: "Checkout, setup Python, install dependencies"
inputs:
python-version:
description: "Python version"
required: false
default: "3.12"
working-directory:
description: "Project directory"
required: false
default: "."
outputs:
cache-hit:
description: "Whether pip cache was hit"
value: ${{ steps.setup-python.outputs.cache-hit }}
runs:
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
id: setup-python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
The key fields
| Field | What it does |
|---|---|
name | The name visible in the Actions logs |
description | The action's documentation |
inputs | The parameters the caller can pass |
outputs | The values the action returns to the caller |
runs.using | It must be "composite" |
runs.steps | The sequence of steps that run |
An important rule: shell is mandatory in run
In composite actions, every step that uses run must specify shell:
# ❌ Error: shell isn't specified
- run: echo "hello"
# ✅ Correct
- run: echo "hello"
shell: bash
This is different from normal workflows where shell: bash is the default. In composite actions, it's explicit.
Using a composite action
From the same repo
# .github/workflows/ci.yml
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-python-project
with:
python-version: "3.12"
- name: Run linter
run: ruff check src/
test:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-python-project
with:
python-version: "3.12"
- name: Run tests
run: pytest tests/ -v
Before the composite action, each job had 12 lines of setup. Now it has 3. And if you need to change the Python version, you change it in a single place (or pass it as an input).
From another repo
steps:
- uses: org/shared-actions/setup-python-project@v1
with:
python-version: "3.12"
The action must be in a public repo, or in a private one with access sharing enabled (just like reusable workflows).
Composite actions for AI workflows
The action: Setup AI Project
# .github/actions/setup-ai-project/action.yml
name: "Setup AI Project"
description: "Setup Python, install deps, verify API key"
inputs:
python-version:
description: "Python version"
required: false
default: "3.12"
openai-api-key:
description: "OpenAI API key for verification"
required: false
default: ""
outputs:
api-available:
description: "Whether the OpenAI API is accessible"
value: ${{ steps.verify-api.outputs.available }}
runs:
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Verify OpenAI API access
id: verify-api
shell: bash
run: |
if [ -z "${{ inputs.openai-api-key }}" ]; then
echo "No API key provided, skipping verification"
echo "available=false" >> $GITHUB_OUTPUT
exit 0
fi
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
https://api.openai.com/v1/models \
-H "Authorization: Bearer ${{ inputs.openai-api-key }}" \
2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then
echo "OpenAI API accessible"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "::warning::OpenAI API returned HTTP $STATUS"
echo "available=false" >> $GITHUB_OUTPUT
fi
The action: Slack Notification
# .github/actions/notify-slack/action.yml
name: "Notify Slack"
description: "Send formatted notification to Slack"
inputs:
webhook-url:
description: "Slack webhook URL"
required: true
status:
description: "Pipeline status: success, failure, rollback"
required: true
message:
description: "Custom message"
required: false
default: ""
run-url:
description: "URL to the workflow run"
required: true
runs:
using: "composite"
steps:
- name: Determine emoji and color
id: format
shell: bash
run: |
case "${{ inputs.status }}" in
success)
echo "emoji=✅" >> $GITHUB_OUTPUT
echo "color=#36A64F" >> $GITHUB_OUTPUT
;;
failure)
echo "emoji=❌" >> $GITHUB_OUTPUT
echo "color=#FF0000" >> $GITHUB_OUTPUT
;;
rollback)
echo "emoji=⚠️" >> $GITHUB_OUTPUT
echo "color=#FFA500" >> $GITHUB_OUTPUT
;;
*)
echo "emoji=ℹ️" >> $GITHUB_OUTPUT
echo "color=#0000FF" >> $GITHUB_OUTPUT
;;
esac
- name: Send notification
shell: bash
run: |
MSG="${{ inputs.message }}"
[ -z "$MSG" ] && MSG="Pipeline ${{ inputs.status }}"
curl -sf -X POST "${{ inputs.webhook-url }}" \
-H "Content-Type: application/json" \
-d "{
\"text\": \"${{ steps.format.outputs.emoji }} $MSG\",
\"attachments\": [{
\"color\": \"${{ steps.format.outputs.color }}\",
\"fields\": [{
\"title\": \"Status\",
\"value\": \"${{ inputs.status }}\",
\"short\": true
}],
\"actions\": [{
\"type\": \"button\",
\"text\": \"View Run\",
\"url\": \"${{ inputs.run-url }}\"
}]
}]
}"
Usage in the pipeline
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-ai-project
with:
python-version: "3.12"
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
- name: Run tests
run: pytest tests/ -v
- name: Run AI checks
if: steps.setup.outputs.api-available == 'true'
run: python scripts/prompt_regression.py
notify:
needs: ci
if: always() && needs.ci.result == 'failure'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/notify-slack
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
status: failure
message: "CI Pipeline failed on ${{ github.ref_name }}"
run-url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
Composite actions vs Reusable workflows
This is the most important decision of this module. The two mechanisms solve different problems:
When to use Composite Actions
Do you want to reuse STEPS inside a job?
→ A Composite Action
Example: "Checkout + setup Python + install deps"
- They're 3 steps
- They run inside the same job
- They don't need their own runner
- Several jobs in the same workflow use them
When to use Reusable Workflows
Do you want to reuse a complete WORKFLOW (with multiple jobs)?
→ A Reusable Workflow
Example: "Lint → Test → AI Checks"
- They're 3 separate jobs
- Each one has its own runner
- Multiple repos use the same flow
- You need parallelism between jobs
The detailed comparison table
| Aspect | Composite Action | Reusable Workflow |
|---|---|---|
| Granularity | Steps (inside a job) | Jobs (a complete workflow) |
| Runners | It uses the caller's runner | It can choose its own runners |
| Parallelism | No (steps are sequential) | Yes (jobs run in parallel) |
| Definition | action.yml | .github/workflows/*.yml |
| Reference | uses: in a step | uses: in a job |
| Secrets | Direct access from the caller | It must pass them explicitly |
| A typical use case | A shared setup | A shared CI pipeline |
| Nesting | It can use other actions | It can call other reusable workflows (max 4 levels) |
The complete decision tree
What do you want to reuse?
│
├─ Steps that repeat inside jobs
│ └─ A Composite Action
│
├─ A complete workflow with multiple jobs
│ └─ A Reusable Workflow
│
├─ Both (common steps + a complete flow)
│ └─ A Composite Action for the steps + a Reusable Workflow for the flow
│ (the reusable workflow uses the composite action internally)
│
└─ Complex logic with JavaScript/Docker
└─ A JavaScript Action or a Docker Action (out of scope)
Comparisons
A composite action vs a bash script
| Aspect | Composite Action | A bash script |
|---|---|---|
| Reuse | uses: in YAML | run: ./scripts/setup.sh |
| Typed inputs | Yes (string, boolean, number) | No (everything is a string) |
| Outputs | $GITHUB_OUTPUT built in | Manual |
Can use uses: | Yes (other actions) | No |
| Debugging | Separate steps in the UI | A single step |
| Recommended | A complex setup with actions | Simple scripts |
A local composite action vs a published one
| Aspect | Local (the same repo) | Published (another repo/the marketplace) |
|---|---|---|
| Reference | uses: ./.github/actions/my-action | uses: org/action@v1 |
| Versioning | Always the current version | Tags/releases |
| Accessibility | Only this repo | Any repo |
| Recommended | For a single repo | For sharing across repos/an org |
Troubleshooting
"Error: Can't find action.yml"
Cause: The path to the composite action is wrong.
Solution: Verify that the file structure is correct:
.github/
actions/
setup-python-project/
action.yml ← This file must exist
And the reference must be:
uses: ./.github/actions/setup-python-project
"Error: Input required and not supplied: shell"
Cause: A step with run doesn't have shell specified.
Solution: Add shell: bash to every step that uses run:
- name: My step
shell: bash # Mandatory in composite actions
run: echo "hello"
"The composite action's outputs don't reach the caller"
Cause: The outputs aren't mapped correctly in action.yml.
Solution: Verify that:
- The step has an
idand writes to$GITHUB_OUTPUT - The
outputsinaction.ymlreferences${{ steps.<id>.outputs.<name> }}
outputs:
my-output:
value: ${{ steps.my-step.outputs.result }}
runs:
using: "composite"
steps:
- id: my-step
shell: bash
run: echo "result=hello" >> $GITHUB_OUTPUT
"The composite action has no access to secrets"
Cause: Composite actions can't access secrets.* directly.
Solution: Pass the secrets as inputs:
# action.yml
inputs:
api-key:
required: true
# In the step, use inputs
- shell: bash
run: curl -H "Authorization: Bearer ${{ inputs.api-key }}" ...
# The caller
- uses: ./.github/actions/my-action
with:
api-key: ${{ secrets.MY_SECRET }}
Exercises
Exercise 1: Create a setup composite action
Create a composite action that does checkout, sets up Python, and installs dependencies. It must accept the Python version as an input.
See solution
Create the file .github/actions/setup-python-deps/action.yml:
name: "Setup Python with Dependencies"
description: "Checkout, setup Python, install deps with caching"
inputs:
python-version:
description: "Python version to use"
required: false
default: "3.12"
runs:
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
Usage:
steps:
- uses: ./.github/actions/setup-python-deps
with:
python-version: "3.12"
- run: pytest tests/ -v
Exercise 2: A composite action with outputs
Create a composite action that runs the tests and returns the coverage as an output.
See solution
# .github/actions/run-tests/action.yml
name: "Run Tests with Coverage"
description: "Run pytest and report coverage"
inputs:
test-directory:
description: "Directory containing tests"
required: false
default: "tests/"
source-directory:
description: "Source code directory for coverage"
required: false
default: "src/"
outputs:
coverage:
description: "Coverage percentage"
value: ${{ steps.coverage.outputs.pct }}
test-result:
description: "Test result (passed/failed)"
value: ${{ steps.test.outputs.result }}
runs:
using: "composite"
steps:
- name: Install test dependencies
shell: bash
run: pip install pytest pytest-cov
- name: Run tests
id: test
shell: bash
continue-on-error: true
run: |
if pytest ${{ inputs.test-directory }} -v \
--cov=${{ inputs.source-directory }} \
--cov-report=term > test-output.txt 2>&1; then
echo "result=passed" >> $GITHUB_OUTPUT
else
echo "result=failed" >> $GITHUB_OUTPUT
fi
cat test-output.txt
- name: Extract coverage
id: coverage
shell: bash
run: |
COV=$(grep "TOTAL" test-output.txt | awk '{print $NF}' | tr -d '%' || echo "0")
echo "pct=${COV}" >> $GITHUB_OUTPUT
echo "Coverage: ${COV}%"
Usage:
steps:
- uses: ./.github/actions/setup-python-deps
- uses: ./.github/actions/run-tests
id: tests
- run: echo "Coverage is ${{ steps.tests.outputs.coverage }}%"
Exercise 3: Decide composite action vs reusable workflow
For each scenario, would you use a composite action or a reusable workflow?
- Checkout + setup Python + install deps (3 steps repeated in 4 jobs)
- A complete lint → test → AI checks pipeline (3 parallel jobs, shared across 3 repos)
- Sending a Slack notification with a specific format (1-2 steps)
- CI + Docker build + deploy (5 jobs with complex dependencies, shared across repos)
See solution
- A composite action. They're steps inside jobs — exactly the use case for composite actions.
- A reusable workflow. They're multiple parallel jobs shared across repos — you need
workflow_call. - A composite action. They're 1-2 steps you want to reuse in different jobs.
- A reusable workflow. The complete flow with multiple jobs and complex dependencies justifies a reusable workflow.
The general rule: if it's steps → a composite action. If it's jobs → a reusable workflow.
Summary
- ✅ Composite actions encapsulate several steps into a single reusable action with an
action.yml - ✅
runs.using: "composite"and a mandatoryshell: bashin every step withrun - ✅ Typed inputs and outputs let you parameterize and return data
- ✅ Local use with
uses: ./.github/actions/my-action, cross-repo withuses: org/repo/action@v1 - ✅ Composite actions for steps (inside a job), reusable workflows for jobs (a complete workflow)
- ✅ AI-specific actions: a setup with API key verification, a notification formatter
- ✅ Secrets get passed as inputs — composite actions don't access
secrets.*directly - ✅ Combine both: a reusable workflow can use composite actions internally
Additional resources
- GitHub Actions — Creating Composite Actions - Official documentation
- GitHub Actions — Action Metadata Syntax - The action.yml reference
- GitHub Actions — Action Inputs and Outputs - Input/output syntax
- GitHub Blog — Composite Actions - The official announcement
- Awesome GitHub Actions - A collection of useful actions
- GitHub Actions — Publishing Actions - Publishing actions in the marketplace