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.ymlfile withruns.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.
| Criterion | Composite actions | Reusable workflows |
|---|---|---|
| Level | Step | Job |
| File | action.yml | .github/workflows/*.yml |
| Call | uses: inside a step | uses: at job level |
| Outputs | Step outputs | Job outputs |
| Secrets | Through ${{ secrets.* }} in the workflow | Passed explicitly |
| Matrix | No | Yes |
| Parallelism | No (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:
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: bashThe required properties
Four keys structure every action.yml file, plus one constraint specific to
composite actions: each run step must declare its shell.
| Property | Description |
|---|---|
name | The action name |
description | A short description |
runs.using | Must be composite |
runs.steps | The list of steps |
shell | Required 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: bashDefining 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: bashDefining 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: bashReading 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.0Pinning 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.
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: bashA 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.
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.
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: bashSecrets 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 SHAActions 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.
-
Create a public repository for the action.
-
Add a
brandingblock toaction.yml:name: 'My Action'description: "What the action does"branding:icon: 'check-circle'color: 'green'# inputs, outputs and runs: see the sections above -
Create a release with a semver tag (
v1.0.0). -
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.
# A specific version taggit tag v1.2.3git push origin v1.2.3
# The major tag (points at the latest v1.x.x)git tag -f v1git push -f origin v1On 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: truePlan 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: bashKey points
- A composite action is a reusable step described in an
action.ymlwithruns.using: composite. - Every
run:step of a composite action must declare ashell:;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 arun:block, otherwise command injection and leaks into the logs. - For publishing: an
action.ymlwithbranding, 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.