5.2 Authoring Composite Actions

Key Takeaways

  • Composite actions combine multiple workflow steps (including shell commands and nested actions) into a single reusable action definition using runs.using: 'composite'.
  • Every single run: step in a composite action MUST explicitly declare the shell: parameter (e.g., shell: bash or shell: pwsh); failure to specify shell causes an immediate parser validation error.
  • Inputs are referenced using ${{ inputs.<input_name> }}, while action outputs must be explicitly mapped in action.yml using value: ${{ steps.<step_id>.outputs.<output_name> }}.
  • The ${{ github.action_path }} context variable resolves to the absolute filesystem path of the action directory, enabling companion scripts inside the action repository to be executed reliably.
  • Composite actions do not support services:, top-level env: blocks, or runner specification (runs-on); environment variables must be declared per step under steps[].env:.
Last updated: August 2026

Authoring Composite Actions

Composite Actions allow workflow authors to consolidate multiple sequential workflow steps—including shell scripts and nested GitHub Actions—into a single reusable action. Prior to composite actions, packaging reusable multi-step automation required writing custom Node.js code or building Docker container images.

With composite actions, developers can write pure YAML to bundle setup routines, custom shell tooling, test suites, and deployment logic into modular, maintainable units. Understanding composite action mechanics, mandatory syntax rules, and context resolution is essential for the GH-200 examination.


1. Anatomy of a Composite Action

A composite action is defined in an action.yml file where runs.using is set to 'composite'. Unlike standard workflow jobs, composite actions define their execution sequence under runs.steps.

+-----------------------------------------------------------------------------+
|                        COMPOSITE ACTION DIRECTORY LAYOUT                    |
|                                                                             |
|   .github/actions/composite-deploy/                                         |
|   ├── action.yml               # Manifest declaring inputs, outputs & steps |
|   ├── scripts/                                                              |
|   │   ├── validate-config.sh   # Companion validation script                |
|   │   └── deploy-k8s.py        # Main execution script                      |
|   └── README.md                # Usage documentation & examples             |
+-----------------------------------------------------------------------------+
name: 'Composite Kubernetes Deployer'
description: 'Installs kubectl, validates manifests, and deploys to target cluster'

inputs:
  kubeconfig-data:
    description: 'Base64 encoded kubeconfig string'
    required: true
  namespace:
    description: 'Target Kubernetes namespace'
    required: false
    default: 'default'
  manifest-path:
    description: 'Path to deployment manifests'
    required: true

outputs:
  deployment-status:
    description: 'Final rollout status'
    value: ${{ steps.rollout.outputs.status }}

runs:
  using: 'composite'
  steps:
    - name: Setup Kubectl Toolchain
      uses: azure/setup-kubectl@v3
      with:
        version: 'v1.28.0'

    - name: Configure Kubeconfig File
      shell: bash
      run: |
        mkdir -p "$HOME/.kube"
        echo "${{ inputs.kubeconfig-data }}" | base64 --decode > "$HOME/.kube/config"
        chmod 600 "$HOME/.kube/config"

    - name: Validate Manifests
      shell: bash
      run: ${{ github.action_path }}/scripts/validate-config.sh "${{ inputs.manifest-path }}"

    - name: Apply Deployment & Capture Status
      id: rollout
      shell: bash
      env:
        TARGET_NS: ${{ inputs.namespace }}
      run: |
        kubectl apply -n "$TARGET_NS" -f "${{ inputs.manifest-path }}"
        STATUS=$(kubectl rollout status deployment -n "$TARGET_NS" --timeout=120s)
        echo "status=${STATUS}" >> "$GITHUB_OUTPUT"

2. The Mandatory shell: Rule

The most critical syntax rule in composite actions—and one of the most frequently tested topics on the GH-200 exam—is the mandatory shell: requirement.

In standard workflow files (.github/workflows/*.yml), if you omit shell: in a run: step, GitHub Actions defaults to bash on Linux/macOS and pwsh (or cmd) on Windows.

[!CAUTION] Composite Action Parsing Requirement: In a composite action.yml, every single run: step MUST explicitly specify the shell: keyword. Composite actions do not inherit default shells from the runner or caller workflow. Omitting shell: causes the workflow parser to reject the action with a fatal schema validation error before execution begins.

Supported Shell Keywords in Composite Actions

  • shell: bash (Bourne-Again SHell — cross-platform on hosted runners via MSYS2 on Windows)
  • shell: sh (POSIX standard shell on Unix)
  • shell: pwsh (PowerShell Core — cross-platform on Linux, macOS, and Windows)
  • shell: powershell (Windows Desktop PowerShell)
  • shell: cmd (Windows Command Prompt)
  • shell: python (Directly executes Python script if Python is in $PATH)

3. Context Variables: github.action_path vs GITHUB_WORKSPACE

When a composite action includes standalone helper scripts, configuration templates, or binary utilities within its own repository, referencing their location on disk requires careful use of context variables.

+-----------------------------------------------------------------------------+
|                  FILESYSTEM LOCATIONS DURING COMPOSITE RUN                  |
|                                                                             |
|   +---------------------------------------------------------------------+   |
|   | RUNNER HOST FILESYSTEM                                              |   |
|   |                                                                     |   |
|   |   $GITHUB_WORKSPACE (/home/runner/work/caller-repo/caller-repo)     |   |
|   |   └── Target application source code checked out by caller          |   |
|   |                                                                     |   |
|   |   ${{ github.action_path }} (/home/runner/work/_actions/org/act/v1)  |   |
|   |   ├── action.yml                                                    |   |
|   |   └── scripts/validate-config.sh  <-- Action's internal script!    |   |
|   +---------------------------------------------------------------------+   |
+-----------------------------------------------------------------------------+

The Critical Distinction:

  • $GITHUB_WORKSPACE / ${{ github.workspace }}: Points to the working directory of the caller repository where actions/checkout unpacked the user's project files.
  • ${{ github.action_path }}: Resolves to the absolute path where the composite action itself is located on the runner. For remote actions, this is inside the runner's internal _actions/ cache directory (e.g., /home/runner/work/_actions/owner/repo/v1).
# ✅ CORRECT: Executes script located inside the action repository
- name: Run Internal Action Script
  shell: bash
  run: bash "${{ github.action_path }}/scripts/audit.sh"

# ❌ INCORRECT: Looks for script inside caller's repository root
- name: Run Failed Script
  shell: bash
  run: bash "./scripts/audit.sh"

4. Input & Output Mapping Mechanics

Accessing Inputs

Within composite action steps, input parameters are referenced using standard expression syntax: ${{ inputs.<input_name> }}.

Passing Outputs to Callers

Passing data from a composite action back to the calling workflow requires two coordinated steps:

  1. Step Output Generation: An internal step writes key-value pairs to the $GITHUB_OUTPUT file descriptor.
  2. Action Manifest Output Mapping: The top-level outputs: block in action.yml maps each public output to the internal step's output using the value: key.
# action.yml
outputs:
  release-id:
    description: 'The created release ID'
    value: ${{ steps.create-rel.outputs.rel_id }} # REQUIRED mapping

runs:
  using: 'composite'
  steps:
    - id: create-rel
      shell: bash
      run: |
        ID=$(curl -s https://api.example.com/release)
        echo "rel_id=${ID}" >> "$GITHUB_OUTPUT"
# caller-workflow.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - id: my-composite
        uses: ./.github/actions/release-action
      - run: echo "Created Release: ${{ steps.my-composite.outputs.release-id }}"

5. Architectural Limitations of Composite Actions

While composite actions are versatile and lightweight, they have specific architectural boundaries that differentiate them from workflows and JavaScript actions:

  1. No services: Container Support: Composite actions cannot define Docker service containers (e.g., PostgreSQL, Redis). Service containers must be defined at the workflow job level.
  2. No Top-Level env: Block in action.yml: Composite actions cannot declare global environment variables under the root of action.yml. Environment variables must be declared within individual steps under steps[].env:.
  3. No runs-on or Job-Level Properties: Composite actions execute within whatever runner environment the caller job provides.
  4. No Native secrets: Declaration in action.yml: Composite manifests cannot define a top-level secrets: schema. Caller workflows pass secrets explicitly via inputs: or inject them into step-level env: variables.
Test Your Knowledge

A developer authors a composite action with the following step definition in action.yml:

runs:
  using: 'composite'
  steps:
    - name: Print Welcome Banner
      run: echo "Starting composite execution..."
When a workflow attempts to execute this action, what is the result?

A
B
C
D
Test Your Knowledge

A composite action stored in a dedicated repository includes a Python script at tools/analyzer.py. A caller workflow in a different repository invokes the composite action. Which expression must the composite action author use to execute tools/analyzer.py regardless of the caller's working directory?

A
B
C
D
Test Your Knowledge

An engineer is designing a composite action that must expose an output named digest generated by an internal shell step. How must action.yml be configured to map this output?

A
B
C
D