Skip to content
Français
CI/CD & Automatisation medium

Composite actions in GitHub Actions

35 min de lecture

Read this page in French

Composite actions let you bundle several steps into one reusable action. Unlike reusable workflows, which operate at job level, a composite action plugs in as a step inside any workflow.

What you will learn

  • Tell apart composite action and reusable workflow, and pick the right one
  • Create an action.yml file with runs.using: composite
  • Define typed, documented inputs and outputs
  • Call a composite action, local or hosted in another repository
  • Secure the steps: SHA pinning, secrets passed through env:
  • Publish and version an action on the GitHub Marketplace

This guide is for people repeating the same sequences of steps across several workflows.

Composite actions versus reusable workflows

Both mechanisms factor out CI/CD code, but at different granularities. A composite action is a step: it slots into an existing job, alongside other steps. A reusable workflow is a whole job: it is called in place of the steps:. The table below sums up when each one wins.

CriterionComposite actionsReusable workflows
LevelStepJob
Fileaction.yml.github/workflows/*.yml
Calluses: inside a stepuses: at job level
OutputsStep outputsJob outputs
SecretsThrough ${{ secrets.* }} in the workflowPassed explicitly
MatrixNoYes
ParallelismNo (sequential)Yes (parallel jobs)

Use composite actions for reusable sequences of steps. Use reusable workflows for complete pipelines made of jobs.

Creating a composite action

A composite action lives in its own action.yml file, at the root of a dedicated folder. That file describes what the action expects (inputs), what it returns (outputs) and the sequence of steps it runs (runs).

The basic structure

A composite action fits in a single action.yml file. It declares the metadata, the inputs, the outputs and the sequence of steps under the runs key. Here is a complete action that installs Node.js and runs the tests:

my-action/action.yml
name: 'Setup and Test'
description: "Set up the environment and run the tests"
author: 'Stéphane Robert'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '20'
outputs:
test-result:
description: 'The test result'
value: ${{ steps.test.outputs.result }}
runs:
using: 'composite' # Marks this as a composite action
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install the dependencies
run: npm ci
shell: bash
- name: Run the tests
id: test
run: |
npm test
echo "result=success" >> "$GITHUB_OUTPUT"
shell: bash

The required properties

Four keys structure every action.yml file, plus one constraint specific to composite actions: each run step must declare its shell.

PropertyDescription
nameThe action name
descriptionA short description
runs.usingMust be composite
runs.stepsThe list of steps
shellRequired on every run step

shell is mandatory

Unlike ordinary workflows, every run step in a composite action must specify a shell. The uses: steps take none, since shell is only valid on a run:.

# ❌ Error: shell is missing
- run: echo "Hello"
# ✅ Correct
- run: echo "Hello"
shell: bash

Defining the inputs

Inputs are the parameters the calling workflow passes to the action. Each one is declared with a description, a required flag, and where relevant a default value.

inputs:
# A required input
environment:
description: 'Target environment'
required: true
# An optional input with a default
node-version:
description: 'Node.js version'
required: false
default: '20'
# A boolean input (passed as a string)
skip-cache:
description: 'Disable the cache'
required: false
default: 'false'

Reading an input: ${{ inputs.input-name }}

Booleans in actions

Inputs are always strings. For booleans, compare against the string:

- if: inputs.skip-cache == 'true'
run: echo "Cache disabled"
shell: bash

Defining the outputs

Outputs expose values computed during the run to the calling workflow. Each output references the output of an internal step through its id, so that step must carry an id.

outputs:
version:
description: 'The detected version'
value: ${{ steps.detect.outputs.version }}
artifact-path:
description: "The artifact path"
value: ${{ steps.build.outputs.path }}
runs:
using: 'composite'
steps:
- name: Detect the version
id: detect
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Build the project
id: build
run: |
npm run build
echo "path=./dist" >> "$GITHUB_OUTPUT"
shell: bash

Reading an output in the calling workflow: the value travels through an env: block before being used in the run:.

- uses: ./my-action
id: setup
- name: Print the detected version
env:
DETECTED_VERSION: ${{ steps.setup.outputs.version }}
run: echo "Version: $DETECTED_VERSION"

Using a composite action

A composite action is invoked like any other action: the uses: keyword inside a step. What changes is where the action comes from, a folder of the current repository or an external one.

A local action (same repository)

An action stored in the current repository is called through a relative path starting with ./. Its code is versioned with the workflow, at the same commit: there is no need to pin it by SHA, it is your own code.

name: CI
on:
push:
branches: [main]
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# An action living in the same repository
- name: Run the local composite action
uses: ./.github/actions/my-action
with:
node-version: '20'

The repository layout:

  • Répertoiremy-repo/
    • Répertoire.github/
      • Répertoireactions/
        • Répertoiremy-action/
          • action.yml
      • Répertoireworkflows/
        • ci.yml
    • Répertoiresrc/

An external action (another repository)

An action hosted in another repository is called with the owner/repo notation. That is third-party code: it is pinned by commit SHA, exactly like a Marketplace action.

- name: Install Node.js through an external action
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'

Pinning the actions you call

An external action is referenced by a tag, a branch or a commit SHA. Only one of those forms is safe: the SHA. A tag (@v4) and a branch (@main) are both mobile references: the maintainer can move them to any code, malicious included, without a single line of your workflow changing.

# ❌ Mobile reference: the maintainer can move it
- uses: actions/setup-node@v4
# ✅ Pinned commit SHA, version in the comment
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0

Pinning and its tooling (Dependabot, pinact) are covered in Pinning actions by SHA.

Concrete examples

Three composite actions taken from real cases: preparing a Node.js environment, running a security pass, deploying to Kubernetes. Each one illustrates a different use of inputs and outputs.

A Node.js setup action with caching

This action factors out the Node.js installation and the npm dependency cache, the sequence you copy into almost every JavaScript pipeline.

.github/actions/setup-node/action.yml
name: 'Setup Node.js with Cache'
description: 'Set up Node.js with an optimised npm cache'
inputs:
node-version:
description: 'Node.js version'
default: '20'
working-directory:
description: 'Working directory'
default: '.'
outputs:
cache-hit:
description: 'Whether the cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
- name: Read the npm cache directory
id: npm-cache-dir
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache npm
id: cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: npm-${{ runner.os }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
restore-keys: npm-${{ runner.os }}-
- name: Install the dependencies
working-directory: ${{ inputs.working-directory }}
run: npm ci
shell: bash

A security scan action

This action bundles two scanners, Trivy for vulnerabilities and Gitleaks for secrets, behind a single uses:. The scan-type input enables the slow pass only when it is worth it.

.github/actions/security-scan/action.yml
name: 'Security Scan'
description: 'Run the security scans (Trivy plus Gitleaks)'
inputs:
scan-type:
description: 'Scan type (full, quick)'
default: 'quick'
fail-on-high:
description: 'Fail on high severity and above'
default: 'true'
outputs:
vulnerabilities-found:
description: 'Whether vulnerabilities were found'
value: ${{ steps.result.outputs.found }}
runs:
using: 'composite'
steps:
- name: Scan for vulnerabilities with Trivy
id: trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
severity: ${{ inputs.fail-on-high == 'true' && 'HIGH,CRITICAL' || 'CRITICAL' }}
exit-code: ${{ inputs.fail-on-high == 'true' && '1' || '0' }}
continue-on-error: true
- name: Evaluate the Trivy result
id: result
env:
TRIVY_OUTCOME: ${{ steps.trivy.outcome }}
run: |
if [ "$TRIVY_OUTCOME" = "failure" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
shell: bash
- name: Detect secrets with Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
if: inputs.scan-type == 'full'
env:
GITHUB_TOKEN: ${{ github.token }}

A deployment action

This action deploys an image to Kubernetes. It handles a secret (the kubeconfig) and variable parameters, a good occasion to show how to pass those values without exposing them.

.github/actions/deploy/action.yml
name: 'Deploy to Kubernetes'
description: "Deploy the application to Kubernetes"
inputs:
environment:
description: 'Environment (staging, production)'
required: true
image:
description: 'Docker image to deploy'
required: true
kubeconfig:
description: 'Base64-encoded kubeconfig'
required: true
outputs:
deployment-url:
description: 'The deployment URL'
value: ${{ steps.deploy.outputs.url }}
runs:
using: 'composite'
steps:
- name: Install kubectl
uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0
- name: Configure the kubeconfig
env:
KUBECONFIG_B64: ${{ inputs.kubeconfig }}
run: |
mkdir -p ~/.kube
printf '%s' "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
shell: bash
- name: Deploy to Kubernetes
id: deploy
env:
DEPLOY_IMAGE: ${{ inputs.image }}
DEPLOY_ENV: ${{ inputs.environment }}
run: |
kubectl set image deployment/app "app=$DEPLOY_IMAGE" -n "$DEPLOY_ENV"
kubectl rollout status deployment/app -n "$DEPLOY_ENV"
URL=$(kubectl get ingress -n "$DEPLOY_ENV" \
-o jsonpath='{.items[0].spec.rules[0].host}')
echo "url=https://$URL" >> "$GITHUB_OUTPUT"
shell: bash

Secrets and parameters always travel through env:

The kubeconfig secret and the image / environment parameters travel through an env: block, never interpolated as ${{ }} directly inside run:. Interpolating a value into a shell script opens a command injection and makes secrets appear in the logs. That reflex is explained in Security: the basics.

Organising your actions

Past two or three actions, where you put them starts to matter. Two approaches dominate: a dedicated repository gathering all the organisation's actions, or actions kept local to the repository they serve.

An actions monorepo

A single repository, often named actions, hosts every shared action of the organisation. Each action occupies a subfolder with its action.yml.

  • Répertoireactions/
    • Répertoiresetup-node/
      • action.yml
    • Répertoiresecurity-scan/
      • action.yml
    • Répertoiredeploy/
      • action.yml
    • Répertoirenotify/
      • action.yml

An action sitting in a subfolder is addressed as org/repo/subfolder. Even inside your own organisation, pin every call by SHA: an internal repository can be compromised like any other.

jobs:
ci:
runs-on: ubuntu-24.04
steps:
- name: Set up Node.js
uses: org/actions/setup-node@<COMMIT-SHA> # v1.4.0, replace org and the SHA
- name: Run the security scan
uses: org/actions/security-scan@<COMMIT-SHA> # v1.4.0, replace org and the SHA

Actions inside the same repository

When an action serves a single project, there is no need to move it out: place it in the repository's .github/actions/. It is versioned with the code and called through a relative path.

  • Répertoiremy-app/
    • Répertoire.github/
      • Répertoireactions/
        • Répertoiresetup/
          • action.yml
        • Répertoiredeploy/
          • action.yml
      • Répertoireworkflows/
        • ci.yml
    • Répertoiresrc/

Publishing an action

A composite action useful to other teams can be published to the GitHub Marketplace, the public directory of actions. Publishing requires some metadata and a versioning discipline.

On the GitHub Marketplace

Publishing happens from the GitHub interface, once the action is ready.

  1. Create a public repository for the action.

  2. Add a branding block to action.yml:

    name: 'My Action'
    description: "What the action does"
    branding:
    icon: 'check-circle'
    color: 'green'
    # inputs, outputs and runs: see the sections above
  3. Create a release with a semver tag (v1.0.0).

  4. Publish to the Marketplace from the Releases page.

Versioning

Maintain two levels of tags: an exact, immutable tag per release (v1.2.3), and a mobile major tag (v1) repointed on every compatible release. Consumers then choose between absolute stability and automatic updates.

Fenêtre de terminal
# A specific version tag
git tag v1.2.3
git push origin v1.2.3
# The major tag (points at the latest v1.x.x)
git tag -f v1
git push -f origin v1

On the consumer side, neither is used directly: you pin the commit SHA behind the tag you want, with the version in a comment (see Pinning the actions you call).

Good practices

A few reflexes make your composite actions reliable and easy for other teams to consume.

Give an id to the steps that produce an output

An action output references the output of an internal step: that step must carry an id, otherwise the value is unreachable.

- name: Build the project
id: build # Required to reference the outputs
run: echo "version=1.0" >> "$GITHUB_OUTPUT"
shell: bash
outputs:
version:
value: ${{ steps.build.outputs.version }}

Document every input and output

A precise description saves consuming teams from reading the code of the action to understand what they may pass.

inputs:
environment:
description: |
The target environment for the deployment.
Supported values: staging, production.
The required permissions vary per environment.
required: true

Plan the failure paths

continue-on-error on a critical step lets you chain a fallback step rather than cutting the job short.

- name: Critical step
id: critical
run: ./critical-script.sh
shell: bash
continue-on-error: true
- name: Handle the failure
if: steps.critical.outcome == 'failure'
run: ./fallback.sh
shell: bash

Key points

  • A composite action is a reusable step described in an action.yml with runs.using: composite.
  • Every run: step of a composite action must declare a shell:; uses: steps take none.
  • Inputs are always strings; outputs reference the output of an internal step carrying an id.
  • A local action is called by relative path (./...); an external action is third-party code pinned by SHA.
  • Secrets and parameters travel through env:, never interpolated as ${{ }} inside a run: block, otherwise command injection and leaks into the logs.
  • For publishing: an action.yml with branding, an exact immutable tag plus a mobile major tag; for consuming, pin the SHA.

For the complete reference, see the official documentation on composite actions.

Next steps

  • Pinning actions by SHA: the pinning to apply to the actions your composite calls internally, without which it passes on the risk it wraps.
  • Supply chain attacks: the real scenarios where a shared action became the entry vector for a whole organisation.
  • Auditing with zizmor: the scanner that reads your composite actions and flags injections, broad permissions and mutable references.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn