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

FieldWhat it does
nameThe name visible in the Actions logs
descriptionThe action's documentation
inputsThe parameters the caller can pass
outputsThe values the action returns to the caller
runs.usingIt must be "composite"
runs.stepsThe 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

AspectComposite ActionReusable Workflow
GranularitySteps (inside a job)Jobs (a complete workflow)
RunnersIt uses the caller's runnerIt can choose its own runners
ParallelismNo (steps are sequential)Yes (jobs run in parallel)
Definitionaction.yml.github/workflows/*.yml
Referenceuses: in a stepuses: in a job
SecretsDirect access from the callerIt must pass them explicitly
A typical use caseA shared setupA shared CI pipeline
NestingIt can use other actionsIt 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

AspectComposite ActionA bash script
Reuseuses: in YAMLrun: ./scripts/setup.sh
Typed inputsYes (string, boolean, number)No (everything is a string)
Outputs$GITHUB_OUTPUT built inManual
Can use uses:Yes (other actions)No
DebuggingSeparate steps in the UIA single step
RecommendedA complex setup with actionsSimple scripts

A local composite action vs a published one

AspectLocal (the same repo)Published (another repo/the marketplace)
Referenceuses: ./.github/actions/my-actionuses: org/action@v1
VersioningAlways the current versionTags/releases
AccessibilityOnly this repoAny repo
RecommendedFor a single repoFor 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:

  1. The step has an id and writes to $GITHUB_OUTPUT
  2. The outputs in action.yml references ${{ 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?

  1. Checkout + setup Python + install deps (3 steps repeated in 4 jobs)
  2. A complete lint → test → AI checks pipeline (3 parallel jobs, shared across 3 repos)
  3. Sending a Slack notification with a specific format (1-2 steps)
  4. CI + Docker build + deploy (5 jobs with complex dependencies, shared across repos)
See solution
  1. A composite action. They're steps inside jobs — exactly the use case for composite actions.
  2. A reusable workflow. They're multiple parallel jobs shared across repos — you need workflow_call.
  3. A composite action. They're 1-2 steps you want to reuse in different jobs.
  4. 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 mandatory shell: bash in every step with run
  • Typed inputs and outputs let you parameterize and return data
  • Local use with uses: ./.github/actions/my-action, cross-repo with uses: 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

  1. GitHub Actions — Creating Composite Actions - Official documentation
  2. GitHub Actions — Action Metadata Syntax - The action.yml reference
  3. GitHub Actions — Action Inputs and Outputs - Input/output syntax
  4. GitHub Blog — Composite Actions - The official announcement
  5. Awesome GitHub Actions - A collection of useful actions
  6. GitHub Actions — Publishing Actions - Publishing actions in the marketplace