Skip to main content

Composite GitHub Actions Reference

Composite actions vs reusable workflows​

This is the most common architectural question teams face. Both reduce duplication — but they operate at different levels.

DimensionComposite actionReusable workflow
Unit of reuseSteps within a jobAn entire job (or set of jobs)
Calling syntaxuses: inside a job's steps:uses: as a top-level jobs.<id>.uses:
SecretsMust be passed as inputs — secrets.* not accessibleCan receive secrets: inherit or explicit mapping
Outputsoutputs: in action.yml → steps.<id>.outputs.*outputs: in the called workflow → jobs.<id>.outputs.*
ConcurrencyInherits the caller job's runner and concurrencyGets its own runner per job; can declare its own concurrency
MatrixCannot define a matrix — runs once per callCan define its own strategy.matrix
Context visibilitySees github.*, runner.*, env.* from callerSees its own github.*; some caller context is absent
PermissionsInherits caller job's token permissionsMust re-declare permissions: — cannot inherit
Log groupingSteps appear inline in the caller job's logJobs appear as separate entries in the workflow run
if: conditionsApplied at the step levelApplied at the job level

When to use each​

Reusing 3–10 steps that always run together → composite action
Reusing an entire job that needs its own runner → reusable workflow
Need secrets:inherit or secrets:inherit shortcut → reusable workflow
Need a matrix strategy defined inside the reused unit → reusable workflow
Caller needs the result as a step output → composite action
Reused unit needs different permissions from caller → reusable workflow

Reusable workflow calling syntax​

# Reusable workflow — top-level jobs key, not inside steps:
jobs:
call-build:
uses: org/actions/.github/workflows/build.yml@v1
with:
image_name: my-service
secrets:
registry_token: ${{ secrets.REGISTRY_TOKEN }}
# Composite action — inside a job's steps:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: org/actions/docker-build-push@v1
with:
image_name: my-service

Using composite actions from private repositories​

When a composite action lives in a private repo (not the same repo as the caller), the runner needs permission to read it.

Pattern 1 — same-repo action (no token needed)​

# Caller workflow in the same repo as the action
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/my-action # relative path — resolves to the checked-out repo

No extra token needed. The default GITHUB_TOKEN has read access to its own repo.

Create a GitHub App with Contents: Read on the actions repo. Install it on the org or the target repo.

jobs:
build:
runs-on: ubuntu-latest
steps:
# 1. Generate a short-lived installation token for the actions repo
- name: Get GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.ACTIONS_APP_ID }}
private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }}
repositories: actions # the repo containing the composite action

# 2. Check out the actions repo so the runner can find action.yml
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: org/actions
ref: v1
token: ${{ steps.app-token.outputs.token }}
path: .actions # check out into a subdirectory

# 3. Reference the local copy
- uses: ./.actions/docker-build-push
with:
image_name: my-service

Why App token over PAT:

  • Scoped to specific repos — principle of least privilege
  • Short-lived (1 hour max) — no long-term credential to rotate
  • Auditable — actions appear as App activity in the audit log
  • No personal account dependency — survives employee offboarding

Pattern 3 — personal access token (avoid in production)​

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: org/actions
token: ${{ secrets.ORG_ACTIONS_PAT }} # fine-grained PAT, Contents:Read
path: .actions
- uses: ./.actions/my-action

Risks: tied to a personal account; requires manual rotation; wide scope if using classic PAT.

Diagnosing "can't find action.yml"​

Error: Can't find 'action.yml', 'action.yaml', or 'Dockerfile' under '/home/runner/work/...'

Checklist:

  1. Is the actions repo private? → needs a token on the checkout step
  2. Is actions/checkout running before the uses: reference?
  3. Is the path: on the checkout step matching the prefix in the uses: path?
  4. Does the branch/tag/SHA in ref: actually have an action.yml at the specified subdirectory?

Organisation action repository strategy​

org/actions/
├── docker-build-push/action.yml
├── notify-slack/action.yml
├── setup-env/action.yml
└── .github/
├── dependabot.yml (one file — updates all action SHAs)
└── workflows/
├── test-docker-build-push.yml
├── test-notify-slack.yml
└── release.yml (tags: v1.2.3 → updates org/actions@v1)

Caller:

- uses: org/actions/docker-build-push@v1
- uses: org/actions/notify-slack@v2

Pros:

  • Single dependabot config updates all pinned SHAs at once
  • One release workflow, one version tag, one floating major tag
  • Easy cross-action discoverability and standardisation
  • Atomic changes that span multiple actions ship together

Cons:

  • A broken release blocks all actions from updating
  • Version is shared — can't release docker-build-push@v2 independently of notify-slack

Per-action repos (use when actions diverge significantly)​

org/action-docker-build-push/action.yml → org/action-docker-build-push@v1
org/action-notify-slack/action.yml → org/action-notify-slack@v1

Pros:

  • Independent versioning and release cadence
  • Separate dependabot, separate test workflow
  • Can be individually Marketplace-published

Cons:

  • Each repo needs its own release workflow, dependabot config, test workflow
  • Dependabot creates separate PRs in every caller repo for every action
  • Hard to enforce consistent standards across many repos

Decision guide​

< 5 actions, same team owns all of them → mono-repo
> 10 actions or multiple owning teams → per-action repos or domain-grouped repos
Some actions go to Marketplace → those actions get their own repos; internal ones in mono-repo
Actions have vastly different release cadences → per-action repos

Floating tag strategy (applies to both)​

# On release of v1.2.3:
git tag v1.2.3
git tag -f v1 # update floating major tag
git push origin v1.2.3
git push origin v1 --force

# Callers pinning to @v1 automatically get v1.2.3
# Callers pinning to SHA are unaffected — intentional

Contents​


When to use composite vs JavaScript vs Docker​

Choose the action type before writing a single line:

NeedUseWhy
Shell commands + existing uses: stepsCompositeZero overhead, no build step, any runner
Complex logic, async, GitHub API calls, JSON parsingJavaScriptFull Node.js, @actions/core, @actions/github
Guaranteed OS/runtime/tool version regardless of runnerDocker containerHermetic environment, but slow cold start (~30s)
Cross-platform: Linux + Windows + macOSJavaScriptComposite can work with OS conditionals but is fragile
Wrap 3–10 steps that repeat across workflowsCompositeSimplest — no compilation, no Docker layer
Call the GitHub REST or GraphQL APIJavaScript@actions/github client is purpose-built
Need persistent state across steps (not $GITHUB_ENV)JavaScriptCan manage state in-process

Default to composite for platform actions. Switch to JavaScript only when you need things composite cannot provide.


action.yml anatomy​

Every composite action requires an action.yml (or action.yaml) at the root of its directory.

name: 'Setup Node with Cache' # shown in the Marketplace and action picker
description: 'Install Node.js, restore npm cache' # one sentence
author: 'Platform Team'

inputs:
node_version:
description: 'Node.js version (e.g. 20.x)'
required: false
default: '20.x'
enable_cache:
description: 'Restore npm cache keyed on package-lock.json'
required: false
default: 'true'
type: boolean # still arrives as a string — compare with == 'true'

outputs:
node_version:
description: 'The Node.js version that was installed'
value: ${{ steps.setup.outputs.node-version }}
cache_hit:
description: 'true if the npm cache was restored'
value: ${{ steps.cache.outputs.cache-hit }}

runs:
using: 'composite' # required — must be the literal string "composite"
steps:
- name: Restore npm cache
id: cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ inputs.node_version }}-${{ hashFiles('**/package-lock.json') }}

- name: Setup Node.js
id: setup
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ inputs.node_version }}

- name: Install dependencies
shell: bash # required on every run: step — composite has no inherited default
run: npm ci --prefer-offline

branding:
icon: 'package' # Feather icon name
color: 'green' # white | yellow | blue | green | orange | red | purple | gray-dark

Mandatory fields: name, description, runs.using: 'composite', shell: on every run: step.


Variables and secrets​

Understanding which mechanism to use and when is the most common source of bugs in composite actions.

MechanismHow to writeHow to readScopeUse for
inputs.<name>Caller sets via with:${{ inputs.name }} in templatesEntire actionPlain config values, secrets passed from caller
env: on a stepenv: KEY: value in step$KEY in shellThat step onlySafely injecting inputs into shell commands
$GITHUB_ENVecho "KEY=val" >> "$GITHUB_ENV"$KEY in shellAll subsequent stepsSharing computed values across steps
$GITHUB_OUTPUTecho "key=val" >> "$GITHUB_OUTPUT"${{ steps.id.outputs.key }}Steps + action outputsExposing step results
$GITHUB_PATHecho "/tool/bin" >> "$GITHUB_PATH"Automatic — added to $PATHAll subsequent stepsMaking a binary available on PATH
$GITHUB_STEP_SUMMARYecho "# Title" >> "$GITHUB_STEP_SUMMARY"Shown in Actions UIJob summary pageRich Markdown summary visible after the run
::add-mask::echo "::add-mask::$VALUE"Redacted as *** in all logsImmediately + all subsequentRuntime-generated secrets (tokens, passwords)

Secrets flow — end-to-end pattern​

Composite actions cannot access ${{ secrets.* }} directly. Secrets must travel as inputs:

Caller's secrets store
│
│ with:
│ webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
▼
inputs:
webhook_url: ← declared as required: true in action.yml
required: true
│
│ env: WEBHOOK_URL: ${{ inputs.webhook_url }}
▼
Shell step ← reads $WEBHOOK_URL — never ${{ inputs.webhook_url }} directly
# action.yml
inputs:
webhook_url:
description: 'Slack incoming webhook URL — pass ${{ secrets.SLACK_WEBHOOK_URL }}'
required: true

runs:
using: 'composite'
steps:
- name: Send notification
shell: bash
env:
WEBHOOK_URL: ${{ inputs.webhook_url }} # safely isolated from shell parsing
run: |
echo "::add-mask::$WEBHOOK_URL" # mask it from logs immediately
curl -s -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"text":"Build complete"}'

Plain variables vs secrets — naming convention​

Clearly distinguish inputs by adding a comment in the action's README inputs table:

InputTypeSecret?Description
image_namestringNoContainer image name
registrystringNoRegistry host (default: ghcr.io)
aws_role_arnstringNoIAM role ARN to assume via OIDC
webhook_urlstringYesPass ${{ secrets.SLACK_WEBHOOK }}
kubeconfigstringYesPass ${{ secrets.KUBECONFIG }}

Context availability in composite actions​

Not all GitHub Actions contexts are available inside composite action steps. Getting this wrong produces silent empty values — no error, just broken behaviour.

ContextAvailable in composite?Notes
github.*Yes — fullgithub.sha, github.ref, github.actor, github.event, etc.
runner.*Yesrunner.os, runner.arch, runner.temp, runner.tool_cache
env.*YesEnv vars set by the caller job or earlier steps in the action
inputs.*YesThe action's own declared inputs
steps.*PartialOnly steps defined within this composite action — not the caller's steps
job.*Partialjob.status works; job.container and job.services are empty
secrets.*NoAlways empty — pass secrets as required: true inputs instead
needs.*NoJob dependency outputs are not visible inside a composite action
matrix.*NoMatrix values are not passed automatically — thread through inputs
strategy.*No

Threading matrix and needs values through inputs​

Because matrix.* and needs.* are invisible inside a composite action, the caller must forward them explicitly:

# Caller job — matrix is defined here
jobs:
build:
strategy:
matrix:
environment: [dev, staging, production]
steps:
- uses: org/actions/deploy@v1
with:
environment: ${{ matrix.environment }} # thread matrix value as an input
previous_sha: ${{ needs.setup.outputs.sha }} # thread needs output as an input

# action.yml — receives the values as plain inputs
inputs:
environment:
description: 'Target environment'
required: true
previous_sha:
description: 'SHA from the setup job'
required: false

steps.* scope​

steps.* inside a composite action only sees steps defined within that action, not steps in the caller's job. To consume a caller step's output inside your action, the caller must pass it as an input:

# Caller
- name: Compute version
id: version
run: echo "value=1.2.3" >> "$GITHUB_OUTPUT"

- uses: org/actions/release@v1
with:
version: ${{ steps.version.outputs.value }} # thread caller step output as input

# action.yml — steps.version is NOT visible here; use inputs.version instead

OIDC cloud trust configuration​

Composite actions like configure-cloud and terraform-plan use OIDC to exchange a GitHub Actions token for cloud credentials. The action side is straightforward — the common blocker is configuring the trust relationship on the cloud side.

AWS — IAM role trust policy​

Create an IAM role with a web identity trust policy that restricts which GitHub repos and branches can assume it:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:org/repo:*"
}
}
}
]
}

Condition key options (use the narrowest that fits):

ConditionExample valueRestricts to
sub contains ref:refs/heads/mainrepo:org/repo:ref:refs/heads/mainMain branch only
sub contains environment:productionrepo:org/repo:environment:productionA specific GitHub Environment
sub wildcardrepo:org/repo:*Any ref in the repo (use with caution)
sub exact pull_requestrepo:org/repo:pull_requestPRs only (read-only roles)

Create the OIDC provider (one-time per AWS account):

aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

The thumbprint is for the GitHub OIDC endpoint certificate. GitHub rotates this — check the current value in GitHub's docs.

Azure — federated credential on the app registration​

In Azure, add a federated credential to an existing app registration (Entra ID → App registrations → your app → Certificates & secrets → Federated credentials):

FieldValue
Federated credential scenarioGitHub Actions deploying Azure resources
Organisationyour-github-org
Repositoryyour-repo
Entity typeBranch / Environment / Pull request / Tag
Based on selectionmain (for branch) or production (for environment)
Namegithub-actions-prod

Via Azure CLI:

az ad app federated-credential create \
--id <app-object-id> \
--parameters '{
"name": "github-actions-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:org/repo:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'

Then grant the service principal the required role:

az role assignment create \
--assignee <service-principal-id> \
--role "Contributor" \
--scope "/subscriptions/<subscription-id>/resourceGroups/<rg>"

Terraform — required provider config for OIDC​

When using OIDC in a Terraform workflow, configure the provider to use web identity:

# AWS — reads OIDC token from ACTIONS_ID_TOKEN_REQUEST_URL automatically
provider "aws" {
region = var.aws_region
# No access_key / secret_key — relies on ambient OIDC credentials set by the action
}

# Azure — uses env vars set by azure/login
provider "azurerm" {
features {}
use_oidc = true
# ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID set by the action
}

Kubernetes cluster authentication​

After OIDC credentials are configured, the runner needs a kubeconfig that points at the cluster. Each cloud has a different CLI command and a non-obvious requirement.

EKS — aws eks update-kubeconfig​

- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: us-east-1

- name: Update kubeconfig for EKS
shell: bash
run: aws eks update-kubeconfig --name my-cluster --region us-east-1

No extra tooling. aws eks update-kubeconfig writes a kubeconfig entry that calls aws eks get-token as a credential exec plugin — token refresh is automatic. The calling IAM role needs eks:DescribeCluster plus a Kubernetes RBAC binding.

Kubernetes RBAC — bind the IAM role:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: github-actions-deploy
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: edit
subjects:
- kind: User
name: arn:aws:iam::123456789012:role/github-actions-deploy
apiGroup: rbac.authorization.k8s.io

AKS — az aks get-credentials + kubelogin​

- name: Azure login via OIDC
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Install kubelogin
shell: bash
timeout-minutes: 3
run: |
az aks install-cli --only-show-errors \
--kubelogin-install-location /usr/local/bin/kubelogin

- name: Get AKS credentials
shell: bash
run: |
az aks get-credentials \
--resource-group my-rg \
--name my-cluster \
--overwrite-existing
kubelogin convert-kubeconfig -l workloadidentity

Why kubelogin is required: AKS clusters with Azure AD / Entra ID integration generate a kubeconfig that uses kubelogin as an exec credential plugin in devicelogin mode — which opens a browser and hangs in CI. kubelogin convert-kubeconfig -l workloadidentity rewrites the exec plugin to use the ambient workload identity tokens already present after azure/login. Skip this step and every kubectl command hangs indefinitely.

az aks install-cli --kubelogin-install-location /usr/local/bin/kubelogin installs only kubelogin, leaving the runner's kubectl version unaffected. If kubelogin is already present, the command is idempotent.

Grant the service principal cluster access:

# Cluster User Role — allows az aks get-credentials
az role assignment create \
--assignee <service-principal-object-id> \
--role "Azure Kubernetes Service Cluster User Role" \
--scope /subscriptions/SUB/resourceGroups/RG/providers/Microsoft.ContainerService/managedClusters/CLUSTER

# Then bind to a Kubernetes Role/ClusterRole inside the cluster using the SP object ID or an AAD group

GKE — google-github-actions/auth + get-gke-credentials​

- name: Authenticate to GCP
uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github
service_account: deployer@my-project.iam.gserviceaccount.com

- name: Get GKE credentials
uses: google-github-actions/get-gke-credentials@20b2b9f6b9a6a5cc0b7a435e26fdb9cf6de2e1fa # v2.3.1
with:
cluster_name: my-cluster
location: us-central1
project_id: my-project

No extra kubeconfig manipulation. get-gke-credentials installs gke-gcloud-auth-plugin and writes a kubeconfig entry that uses it — token refresh is automatic.

Workload Identity Federation setup (Terraform):

resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-actions"
}

resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-provider"
oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
}
attribute_condition = "assertion.repository == 'org/repo'"
}

resource "google_service_account_iam_member" "wif_binding" {
service_account_id = google_service_account.deployer.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/org/repo"
}

Required GKE IAM roles:

  • roles/container.developer — full kubectl access (broad)
  • Or roles/container.viewer + a Kubernetes RBAC ClusterRoleBinding for least privilege

Decision table​

EKSAKSGKE
Kubeconfig commandaws eks update-kubeconfigaz aks get-credentialsget-gke-credentials action
Extra tool neededNonekubelogin (scoped install via az aks install-cli)gke-gcloud-auth-plugin (action installs it)
Non-interactive quirkNoneMust run kubelogin convert-kubeconfig -l workloadidentityNone
OIDC trust setupIAM role trust policyApp registration federated credentialWIF pool + provider + SA IAM binding
Kubernetes RBAC subjectIAM role ARNAAD group or SP object IDService account email

Inputs and outputs​

Input types​

inputs:
# String (default)
image_name:
description: 'Container image name'
required: true

# Choice — validates at workflow parse time
log_level:
description: 'Logging verbosity'
required: false
default: 'info'
type: choice
options: [debug, info, warn, error]

# Boolean — arrives as string; always compare with == 'true'
push:
description: 'Push the image after build'
required: false
default: 'true'
type: boolean

Two ways to read an input​

# 1. Template expression — use in YAML values only, never directly in run: commands
${{ inputs.image_name }}

# 2. Environment variable — always use this inside shell scripts
# Name rule: INPUT_ prefix + uppercased name, hyphens → underscores
$INPUT_IMAGE_NAME

Producing outputs​

runs:
using: 'composite'
steps:
- name: Compute short SHA tag
id: tag
shell: bash
run: |
SHORT="${GITHUB_SHA:0:7}"
echo "value=$SHORT" >> "$GITHUB_OUTPUT"

outputs:
image_tag:
description: 'Short SHA used as the image tag'
value: ${{ steps.tag.outputs.value }} # must reference a step id

Chaining outputs across steps​

- name: Build image
id: build
shell: bash
run: |
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' myapp:latest)
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

- name: Sign image
shell: bash
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign "$IMAGE_DIGEST"

Security requirements​

1. Pin every external action to a full commit SHA​

# ❌ Tag — mutable, can be rewritten to point to malicious code
- uses: actions/checkout@v4

# ✅ SHA — immutable, tied to the exact code reviewed; add version as comment
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Use pinact to resolve and update SHAs automatically.

2. Declare shell: on every run: step​

Composite actions have no inherited shell. Omitting it errors or falls back silently.

# ❌
- run: echo "hello"

# ✅
- shell: bash
run: echo "hello"

3. Secrets must come in as inputs​

# ✅ action.yml
inputs:
github_token:
description: 'GitHub token — pass ${{ secrets.GITHUB_TOKEN }}'
required: true

# ✅ caller workflow
- uses: org/repo/actions/deploy@abc123
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

4. Never interpolate inputs directly in run: — use env:​

# ❌ Shell injection — inputs.environment is expanded before bash parses
- shell: bash
run: deploy.sh --env ${{ inputs.environment }}

# ✅ Data, not code — bash sees the value as an env var, not a command
- shell: bash
env:
DEPLOY_ENV: ${{ inputs.environment }}
run: |
case "$DEPLOY_ENV" in
dev|staging|production) ;;
*) echo "::error::Invalid environment: $DEPLOY_ENV" && exit 1 ;;
esac
deploy.sh --env "$DEPLOY_ENV"

5. Mask secrets and runtime-generated tokens immediately​

- name: Retrieve ephemeral token
id: token
shell: bash
run: |
TOKEN=$(curl -s https://auth.example.com/token | jq -r '.access_token')
echo "::add-mask::$TOKEN"
echo "value=$TOKEN" >> "$GITHUB_OUTPUT"

6. Use ${{ github.action_path }} — never relative paths​

# ❌ Breaks when the caller's CWD differs from the action's directory
- shell: bash
run: ./scripts/validate.sh

# ✅ Always resolves to the action's own directory
- shell: bash
run: |
chmod +x "${{ github.action_path }}/scripts/validate.sh"
"${{ github.action_path }}/scripts/validate.sh"

7. Document the minimum GITHUB_TOKEN permissions in the README​

Composite actions inherit the caller job's token permissions. Always document:

# Minimum permissions required by this action
permissions:
contents: read
packages: write # push to GHCR
id-token: write # OIDC

Observability​

Every production-grade composite action should write a job summary, group its output, and emit inline annotations.

Job summary — $GITHUB_STEP_SUMMARY​

The job summary appears in the GitHub Actions UI after the run and is linked from the PR checks.

- name: Write job summary
if: always()
shell: bash
run: |
{
echo "## Build Result"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Image | \`${{ inputs.registry }}/${{ inputs.image_name }}\` |"
echo "| Tag | \`${{ steps.tag.outputs.value }}\` |"
echo "| Digest | \`${{ steps.push.outputs.digest }}\` |"
echo "| Status | $([[ '${{ job.status }}' == 'success' ]] && echo '✅ Success' || echo '❌ Failed') |"
} >> "$GITHUB_STEP_SUMMARY"

Log grouping — ::group:: / ::endgroup::​

Collapsible sections make long logs readable in the Actions UI.

- name: Install and validate
shell: bash
run: |
echo "::group::Install dependencies"
npm ci
echo "::endgroup::"

echo "::group::Run lint"
npm run lint
echo "::endgroup::"

Annotations — inline PR feedback​

Annotations appear in the PR "Files changed" view as inline comments.

# Error — fails the check (shown as ❌ in PR)
echo "::error file=src/app.ts,line=42,col=5::Null pointer dereference"

# Warning — advisory, does not fail (shown as ⚠️)
echo "::warning file=terraform/main.tf,line=10::Deprecated resource type"

# Notice — informational (shown as ℹ️)
echo "::notice::Image built successfully: $IMAGE_TAG"

Debug logging — ::debug:: and RUNNER_DEBUG​

::debug:: writes a message that only appears when debug logging is enabled. Use it for verbose diagnostic output that would clutter normal runs:

- name: Compute cache key
shell: bash
run: |
KEY="${{ runner.os }}-node-${{ inputs.version }}-${{ hashFiles('**/package-lock.json') }}"
echo "::debug::Cache key: $KEY"
echo "cache_key=$KEY" >> "$GITHUB_OUTPUT"

RUNNER_DEBUG=1 is also set when debug logging is enabled — use it for conditional verbose blocks:

- name: Run deployment
shell: bash
run: |
if [[ "${RUNNER_DEBUG:-0}" == "1" ]]; then
set -x # echo every command
kubectl get all -n "$NAMESPACE"
fi
kubectl apply -f manifest.yml
env:
NAMESPACE: ${{ inputs.namespace }}

Enable from the repo → Actions → "Re-run jobs" → "Enable debug logging", or set ACTIONS_STEP_DEBUG=true as a repository secret for permanent debug output.

Logging command summary:

CommandVisibilityUse for
::debug::messageDebug runs onlyVerbose diagnostic info, cache keys, computed values
::notice::messageAlways — blue annotationInformational milestones (release URL, tag created)
::warning::messageAlways — yellow annotationNon-fatal issues (deprecated input, skipped step)
::error::messageAlways — red annotationFatal errors (pair with exit 1)
echo "plain text"Always — no annotationProgress messages inside ::group:: blocks

Input validation — fail fast​

Validate all inputs at the top of the first step before doing any real work. Give developers a clear, actionable error message.

- name: Validate inputs
shell: bash
env:
IMAGE_NAME: ${{ inputs.image_name }}
ENVIRONMENT: ${{ inputs.environment }}
SEVERITY: ${{ inputs.severity }}
run: |
ERRORS=()

# Required inputs
[[ -z "$IMAGE_NAME" ]] && ERRORS+=("image_name is required")

# Enum validation
case "$ENVIRONMENT" in
dev|staging|production) ;;
*) ERRORS+=("environment must be one of: dev, staging, production (got: $ENVIRONMENT)") ;;
esac

# Pattern validation
if [[ -n "$SEVERITY" ]] && ! echo "$SEVERITY" | grep -qE '^(UNKNOWN|LOW|MEDIUM|HIGH|CRITICAL)(,(UNKNOWN|LOW|MEDIUM|HIGH|CRITICAL))*$'; then
ERRORS+=("severity must be comma-separated severity levels (e.g. HIGH,CRITICAL)")
fi

# Report all errors at once
if [[ ${#ERRORS[@]} -gt 0 ]]; then
for err in "${ERRORS[@]}"; do
echo "::error::$err"
done
exit 1
fi

Step control flow​

Step IDs​

Add id: to every step whose result is referenced by a later step or exposed as an action output.

- name: Build image
id: build # referenced as steps.build.outputs.digest below
uses: docker/build-push-action@...
with:
push: true

Conditional steps​

# Run only when input flag is set
- name: Push image
if: inputs.push == 'true'
shell: bash
run: docker push "$IMAGE_URI"

# Run on failure — e.g., cleanup or notification
- name: Notify on failure
if: failure()
shell: bash
env:
WEBHOOK: ${{ inputs.slack_webhook_url }}
run: |
curl -s -X POST "$WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Build failed in ${{ github.repository }}@${{ github.sha }}\"}"

# Always run — e.g., upload logs, write summary
- name: Upload logs
if: always()
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1029d08800d6f4dc # v4.5.0
with:
name: build-logs-${{ github.run_id }}
path: '*.log'

continue-on-error vs if: always() — critical difference​

These look similar but have opposite semantics. Using the wrong one is a silent bug.

Behaviourcontinue-on-error: trueif: always()
Step runs when previous step fails?Only on that specific stepYes — any subsequent step with if: always()
steps.<id>.outcomefailure (correct)failure (correct)
steps.<id>.conclusionsuccess — always, even if it failedfailure if it failed
Job outcome affected by step failure?No — failure is swallowedYes — job still fails unless if: always() step masks it
Use forOptional steps whose failure should not block the jobCleanup/summary steps that must always run
# ✅ continue-on-error: true — optional step, failure is ignored for job outcome
- name: Optional security scan
id: scan
continue-on-error: true
uses: aquasecurity/trivy-action@...
with:
exit-code: '1'

- name: Report scan result
shell: bash
run: |
# steps.scan.conclusion == 'success' even if scan found CVEs and exited 1
# Use steps.scan.outcome (not conclusion) to check the real result
echo "Scan outcome: ${{ steps.scan.outcome }}"

# ✅ if: always() — cleanup must run regardless of what failed above
- name: Delete temp kubeconfig
if: always()
shell: bash
run: rm -f "$KUBECONFIG_TMPFILE"

The footgun: using continue-on-error: true on a cleanup step when you meant if: always(). If the prior step succeeded, both work. But if the prior step failed, a cleanup step without if: always() is skipped, leaving credentials or temp files behind.

# ❌ Wrong — cleanup is SKIPPED if the deploy step fails
- name: Deploy
run: kubectl apply -f manifest.yml
- name: Cleanup
continue-on-error: true # this only affects THIS step's failure, not whether it runs
run: rm -f /tmp/kubeconfig

# ✅ Correct — cleanup always runs
- name: Deploy
run: kubectl apply -f manifest.yml
- name: Cleanup
if: always()
run: rm -f /tmp/kubeconfig

Allow a step to fail without failing the action​

- name: Optional lint
id: lint
shell: bash
continue-on-error: true
run: npm run lint

- name: Report lint result
shell: bash
run: |
# Use .outcome (actual result), not .conclusion (always success with continue-on-error)
if [[ "${{ steps.lint.outcome }}" == "failure" ]]; then
echo "::warning::Lint failed — see output above"
fi

File references and scripts​

Always use ${{ github.action_path }}​

actions/
└── k8s-deploy/
├── action.yml
└── scripts/
└── deploy.sh
- name: Deploy to Kubernetes
shell: bash
run: |
chmod +x "${{ github.action_path }}/scripts/deploy.sh"
"${{ github.action_path }}/scripts/deploy.sh"
env:
KUBECONFIG_CONTENT: ${{ inputs.kubeconfig }}
NAMESPACE: ${{ inputs.namespace }}

Add the action directory to $PATH​

For actions that ship multiple scripts, add the directory to PATH once:

- name: Add action scripts to PATH
shell: bash
run: echo "${{ github.action_path }}/scripts" >> "$GITHUB_PATH"

- name: Deploy
shell: bash
run: deploy.sh # found via PATH

Scripts checked in from macOS may lack the execute bit. Always chmod +x before invoking.


Multi-OS patterns​

Composite actions run on the caller's runner. Handle Linux, macOS, and Windows differences explicitly.

- name: Set platform-specific paths
shell: bash
run: |
if [[ "${{ runner.os }}" == "Windows" ]]; then
echo "TOOL_PATH=C:\\tools\\bin" >> "$GITHUB_ENV"
echo "EXT=.exe" >> "$GITHUB_ENV"
else
echo "TOOL_PATH=/usr/local/bin" >> "$GITHUB_ENV"
echo "EXT=" >> "$GITHUB_ENV"
fi

- name: Install tool
shell: bash
run: |
if [[ "${{ runner.os }}" == "macOS" ]]; then
brew install mytool
elif [[ "${{ runner.os }}" == "Linux" ]]; then
sudo apt-get install -y mytool
else
choco install mytool
fi

# Windows steps use pwsh, not bash
- name: Windows-only step
if: runner.os == 'Windows'
shell: pwsh
run: Write-Host "Running on Windows"

Path separators​

- name: Construct path
shell: bash
env:
BASE_DIR: ${{ inputs.base_dir }}
run: |
# Use forward slashes even on Windows in bash shell
FULL_PATH="$BASE_DIR/config/app.yml"
echo "config_path=$FULL_PATH" >> "$GITHUB_OUTPUT"

Idempotency​

Document idempotency behaviour for each action. An action is idempotent when running it twice produces the same result without error.

Patterns:

# Pattern 1 — Skip if already done
- name: Check if image tag exists
id: check
shell: bash
run: |
if docker manifest inspect "${{ inputs.registry }}/${{ inputs.image_name }}:${{ inputs.image_tag }}" > /dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::Image already exists — skipping build"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Build and push
if: steps.check.outputs.exists == 'false'
uses: docker/build-push-action@...

# Pattern 2 — Upsert (create or update)
- name: Create or update Kubernetes deployment
shell: bash
run: |
kubectl apply -f manifest.yml # apply is idempotent by design

# Pattern 3 — Check before delete
- name: Delete old tag if exists
shell: bash
run: |
if git tag -l "${{ inputs.version }}" | grep -q .; then
git tag -d "${{ inputs.version }}"
fi
git tag "${{ inputs.version }}"

Every action's README should include an Idempotency section stating whether it is safe to re-run.


Timeout and concurrency​

Step-level timeout​

- name: Wait for deployment to stabilise
shell: bash
timeout-minutes: 10 # fail this step if it runs longer than 10 minutes
run: |
kubectl rollout status deployment/"$DEPLOYMENT" -n "$NAMESPACE" --timeout=600s
env:
DEPLOYMENT: ${{ inputs.deployment_name }}
NAMESPACE: ${{ inputs.namespace }}

- name: Call external API
shell: bash
timeout-minutes: 2
run: curl --max-time 90 -s "$API_URL"
env:
API_URL: ${{ inputs.api_url }}

Concurrency — caller's responsibility​

Composite actions do not control job-level concurrency. Document the recommended concurrency: block in the action's README:

# Recommended in the caller workflow when using deploy or release actions
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false # false for deploy/release; true for validation/build

Testing​

Test workflow — local path reference​

Reference the action with ./.github/actions/<name> or ./actions/<name> so the PR under review is what gets tested.

name: Test composite action

on:
push:
paths:
- 'actions/**'
- '.github/workflows/test-action.yml'
pull_request:
paths:
- 'actions/**'

jobs:
test-defaults:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Run action with default inputs
id: result
uses: ./actions/setup-env

- name: Verify outputs
shell: bash
run: |
echo "node_version: ${{ steps.result.outputs.node_version }}"
node --version | grep -q "^v20\." || { echo "::error::Wrong Node version"; exit 1; }

test-matrix:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node_version: ['18.x', '20.x', '22.x']
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./actions/setup-env
with:
node_version: ${{ matrix.node_version }}

Local testing with act​

brew install act

# Run with a full Ubuntu image
act -P ubuntu-latest=catthehacker/ubuntu:act-22.04 \
-W .github/workflows/test-action.yml

# With secrets
act -W .github/workflows/test-action.yml \
--secret SLACK_WEBHOOK_URL=https://hooks.slack.com/...

Static validation​

# actionlint lints workflows, and through each `uses: ./actions/<name>` it checks
# the inputs passed against that action's action.yml. It does not lint
# action.yml itself: `actionlint action.yml` fails with '"jobs" section is missing'.
actionlint .github/workflows/*.yml

# action.yml structure, with yq (v4). The first prints composite; the other two
# print nothing when clean.
yq '.runs.using' actions/setup-env/action.yml
# Keys a composite step does not accept (timeout-minutes stops the action loading)
yq '.runs.steps[] | keys | .[] | select(test("^(continue-on-error|env|id|if|name|run|shell|uses|with|working-directory)$") | not)' actions/setup-env/action.yml
# run: steps without shell:
yq '.runs.steps[] | select(has("run") and (has("shell") | not)) | .name' actions/setup-env/action.yml

# Check all external actions are SHA-pinned
grep -rn 'uses:' actions/ \
| grep -vE '(@[0-9a-f]{40}|\./)' \
| grep -v '#'

Breaking changes​

A breaking change is any change that requires a caller to update their with: block or outputs.* references.

What counts as breaking:

  • Renaming or removing an input
  • Changing an input from optional to required
  • Renaming or removing an output
  • Changing the format of an output value

How to handle:

# Step 1 — Add a deprecation warning on the old input (minor version)
- name: Check for deprecated inputs
shell: bash
run: |
if [[ -n "$OLD_INPUT" ]]; then
echo "::warning::Input 'aws_role' is deprecated. Use 'aws_role_arn' instead. This will be removed in v3."
fi
env:
OLD_INPUT: ${{ inputs.aws_role }}

# Step 2 — Support both old and new inputs in the same minor version
- name: Resolve role ARN
id: role
shell: bash
run: |
ARN="${ROLE_ARN:-$OLD_ROLE}"
[[ -z "$ARN" ]] && echo "::error::Provide aws_role_arn" && exit 1
echo "arn=$ARN" >> "$GITHUB_OUTPUT"
env:
ROLE_ARN: ${{ inputs.aws_role_arn }}
OLD_ROLE: ${{ inputs.aws_role }}

# Step 3 — Remove old input in next major version

In the CHANGELOG:

## [v2.0.0] - 2026-05-23
### Breaking changes
- `aws_role` input renamed to `aws_role_arn` — update your `with:` block
### Migration
Replace `aws_role: arn:...` with `aws_role_arn: arn:...`

Versioning and release​

v1.0.0 — initial release
v1.0.1 — patch: fix shell quoting bug
v1.1.0 — minor: add enable_cache input
v2.0.0 — major: rename aws_role → aws_role_arn (breaking)
v1 — floating tag, always points to latest v1.x.x

Release workflow​

name: Release action

on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Validate action.yml
shell: bash
# yq ships on the ubuntu runner image, so nothing is downloaded here.
# actionlint is a workflow linter: run on action.yml it reports
# '"jobs" section is missing' and fails every release.
run: |
set -euo pipefail
errors=0
using="$(yq '.runs.using' action.yml)"
if [[ "$using" != "composite" ]]; then
echo "::error file=action.yml::runs.using is '$using', expected composite"; errors=1
fi
# The only keys a composite step accepts; any other, timeout-minutes
# included, stops the action from loading.
unknown="$(yq '.runs.steps[] | keys | .[] | select(test("^(continue-on-error|env|id|if|name|run|shell|uses|with|working-directory)$") | not)' action.yml)"
if [[ -n "$unknown" ]]; then
echo "::error file=action.yml::unsupported step keys: ${unknown//$'\n'/, }"; errors=1
fi
noshell="$(yq '.runs.steps[] | select(has("run") and (has("shell") | not)) | (.name // "unnamed step")' action.yml)"
if [[ -n "$noshell" ]]; then
echo "::error file=action.yml::run steps without shell: ${noshell//$'\n'/, }"; errors=1
fi
exit "$errors"

- name: Verify SHA pinning
shell: bash
run: |
UNPINNED=$(grep -n 'uses:' action.yml \
| grep -vE '@[0-9a-f]{40}' | grep -v '\./' || true)
[[ -z "$UNPINNED" ]] || { echo "::error::$UNPINNED"; exit 1; }

- name: Update floating major version tag
shell: bash
env:
# Never expand github.ref_name straight into the shell: a tag name is
# attacker-controllable and ${{ }} is substituted before bash parses it.
REF_NAME: ${{ github.ref_name }}
run: |
MAJOR="${REF_NAME%%.*}"
git tag -f "$MAJOR"
git push origin "$MAJOR" --force

- name: Create GitHub release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const tag = context.ref.replace('refs/tags/', '');
await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: tag,
generate_release_notes: true,
});

Dependabot configuration​

Add this to every action repo to auto-receive SHA updates when upstream actions release new versions:

# .github/dependabot.yml
version: 2
updates:
# Keep GitHub Actions up to date
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
commit-message:
prefix: "chore(deps)"
labels:
- "dependencies"
- "github-actions"
groups:
github-actions:
patterns:
- "*"
# Also update actions within composite action steps
- package-ecosystem: "github-actions"
directory: "/actions"
schedule:
interval: "weekly"

Passing state to post-steps — $GITHUB_STATE​

Composite actions do not support post: steps natively (that is a JavaScript/Docker action feature). For cleanup that must run after the main steps (e.g. deleting a temp file), use if: always() at the end of your step list.

If you wrap a composite action in a JavaScript action that calls it, $GITHUB_STATE lets the main run pass state to the post: phase:

# In main step — save state
echo "KUBECONFIG_PATH=/tmp/kube-abc123" >> "$GITHUB_STATE"

# In post: step — read state
echo "Cleaning up ${STATE_KUBECONFIG_PATH}"
rm -f "${STATE_KUBECONFIG_PATH}"

For pure composite actions: use if: always() cleanup steps and store the path in $GITHUB_ENV so later steps can reference it:

steps:
- name: Write kubeconfig
id: write-kube
shell: bash
run: |
TMPFILE=$(mktemp /tmp/kubeconfig-XXXXXX)
echo "$KUBECONFIG_B64" | base64 -d > "$TMPFILE"
chmod 600 "$TMPFILE"
echo "path=$TMPFILE" >> "$GITHUB_OUTPUT"
echo "KUBECONFIG_TMPFILE=$TMPFILE" >> "$GITHUB_ENV"
env:
KUBECONFIG_B64: ${{ inputs.kubeconfig }}

# ... main steps ...

- name: Cleanup kubeconfig
if: always() # runs even if earlier steps fail
shell: bash
run: |
rm -f "$KUBECONFIG_TMPFILE"
echo "::notice::Temporary kubeconfig deleted"

Action composition — calling composite from composite​

A composite action can call other composite actions via uses:. This enables layered abstractions without duplicating logic.

# inner-action/action.yml
name: 'Setup credentials'
runs:
using: 'composite'
steps:
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: ${{ inputs.role_arn }}
aws-region: ${{ inputs.aws_region }}

# outer-action/action.yml — calls the inner action
name: 'Deploy to ECS'
runs:
using: 'composite'
steps:
- name: Setup credentials
uses: org/repo/inner-action@v1 # calls the inner composite action
with:
role_arn: ${{ inputs.role_arn }}
aws_region: ${{ inputs.aws_region }}

- name: Deploy
shell: bash
run: aws ecs update-service ...

Constraints:

  • Nested composite actions still cannot access ${{ secrets.* }} — secrets must be threaded through as inputs at every layer
  • Each layer's inputs must explicitly declare and pass secret inputs down; there is no automatic propagation
  • Circular references are not allowed and will cause a workflow parse error
  • Pin the inner action to a SHA, not a branch, to avoid chained supply-chain risk

Self-hosted runner considerations​

Composite actions run on whatever runner the caller provides. When that is a self-hosted runner, several assumptions that hold for GitHub-hosted runners may break.

AssumptionGitHub-hostedSelf-hosted riskMitigation
bash at /bin/bashAlways presentMay use sh, dash, or be non-LinuxDeclare shell: bash; test on runner OS
Tool availability (curl, jq, docker)Pre-installed on ubuntu-latestNot guaranteedInstall the tool in a step, or document requirement
$HOME / ~ pathsPredictable per jobMay be shared across jobs if runner is persistentUse mktemp for temp files; clean up with if: always()
Clean environmentFresh VM per jobPersistent runner may have leftover env vars from prior jobNamespace your env vars; don't rely on env state
$GITHUB_WORKSPACEUnder /home/runner/work/May be on a different mountAlways use $GITHUB_WORKSPACE — never hardcode
Cachingactions/cache backed by GitHub's CDNMay be slower or misconfiguredDocument cache action as optional; test with enable_cache: false
Network accessOpen internetMay be restricted to internal registry onlyAccept registry host as input; don't hardcode ghcr.io

Document requirements in README:

## Runner requirements

This action works on both GitHub-hosted and self-hosted runners. Self-hosted runners must have:
- `bash` >= 4.0
- `curl`
- `docker` (for image scan target only)
- Internet access to `trivy.dev` for database updates (or set `trivy_version` to a pre-cached binary)

Ephemeral runner security​

Ephemeral (single-use) runners are the recommended security posture for composite actions that handle secrets. Persistent runners accumulate risk:

RiskPersistent runnerEphemeral runner
Secret leakage to next jobSecrets in $GITHUB_ENV persist if cleanup failsVM destroyed after job — no carry-over
Malicious process left from prior jobCan intercept secrets of the next jobNo prior job exists
Disk artifacts (kubeconfig, token files)Must be manually cleaned with if: always()Disappear with the VM
Compromised runner from supply-chain attackAffects all subsequent jobsBlast radius is one job

Enforce ephemeral runners via org policy (GitHub Enterprise):

# Runner group: require ephemeral
gh api \
--method PATCH \
/orgs/{org}/actions/runner-groups/{group_id} \
-f runs_on_self_hosted_runners_only=true \
-f runners_type="ephemeral"

In your action's README, call out when ephemeral runners matter:

## Security note — ephemeral runners recommended

This action handles a `kubeconfig` secret. Run it on **ephemeral** (single-use) runners so the
temporary kubeconfig file cannot be accessed by subsequent jobs on the same machine.

GitHub-hosted runners (`ubuntu-latest`) are ephemeral by default.
For self-hosted runners, configure the runner group to use `--ephemeral` mode.

Troubleshooting​

Error: shell is required for run: step in composite action​

Error: required property is missing: shell

Cause: Composite actions have no inherited default shell.

Fix: Add shell: bash (or shell: pwsh on Windows) to every run: step.


Error: ${{ secrets.MY_SECRET }} is empty inside the action​

Cause: Composite actions cannot access ${{ secrets.* }} directly — it resolves to an empty string.

Fix: Pass the secret as a required input in action.yml:

# action.yml
inputs:
my_token:
description: 'Pass ${{ secrets.MY_SECRET }}'
required: true

# caller workflow
- uses: org/repo/my-action@v1
with:
my_token: ${{ secrets.MY_SECRET }}

Error: ::add-mask:: not masking the value​

Symptom: The secret value appears in plain text in the logs.

Causes and fixes:

CauseFix
::add-mask:: called after the value was already loggedMove ::add-mask::$VALUE to be the first command in the step
The secret was interpolated in YAML before the step ranStop using ${{ inputs.secret }} in run: — use env: block + $VAR
The value was base64-encoded and decoded in shellMask the decoded value, not the encoded one

Error: uses: ./ not found when calling the action​

Symptom:

Error: Can't find 'action.yml', 'action.yaml', or 'Dockerfile'

Causes:

  • actions/checkout step is missing — the action directory is not present on disk
  • Path is wrong — the action lives at .github/actions/my-action/ but uses: ./ looks at the root

Fix:

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/my-action # matches the directory where action.yml lives

actionlint error: shellcheck reported issue in this script​

actionlint runs shellcheck on run: blocks by default. Common fixes:

# SC2086: quote variable
echo $FOO # ❌
echo "$FOO" # ✅

# SC2181: use direct exit code check
cmd; if [ $? -ne 0 ]; then # ❌
if ! cmd; then # ✅

# SC2155: declare and assign separately
export FOO=$(bar) # ❌
FOO=$(bar); export FOO # ✅

# Suppress a specific check (last resort)
# shellcheck disable=SC2046
git add -- $(cat changed-files.txt) # intentional word splitting: one path per line

To run actionlint locally:

# Install: Homebrew on macOS; elsewhere a pinned release binary from
# github.com/rhysd/actionlint/releases, verified against its checksums file
brew install actionlint

# Lint all workflows in a repo (shellcheck runs on their run: blocks when installed)
actionlint .github/workflows/*.yml

actionlint does not lint action.yml on its own (actionlint action.yml fails with "jobs" section is missing), so this check never reaches a composite action's run: blocks. For action.yml, use the yq checks under Static validation.


Output is empty — steps.<id>.outputs.<key> resolves to empty string​

Cause A: Step id: is missing

- name: Set tag
shell: bash # missing id:
run: echo "tag=v1.2.3" >> "$GITHUB_OUTPUT"

Fix: Add id: set-tag and reference steps.set-tag.outputs.tag.

Cause B: Writing to $GITHUB_OUTPUT with wrong syntax

# ❌ — colon instead of equals
echo "tag:v1.2.3" >> "$GITHUB_OUTPUT"

# ✅
echo "tag=v1.2.3" >> "$GITHUB_OUTPUT"

Cause C: Multi-line output needs delimiter syntax

# ❌ — newlines in value break the key=value format
echo "body=$(cat NOTES.md)" >> "$GITHUB_OUTPUT"

# ✅ — EOF delimiter
{
echo "body<<EOF"
cat NOTES.md
echo "EOF"
} >> "$GITHUB_OUTPUT"

The floating major tag v1 was not updated​

Symptom: Callers using @v1 still run the old version after a v1.2.0 release.

Cause: The release workflow git push --force failed, or the release.yml was not triggered (wrong tag pattern).

Check:

gh run list --workflow release.yml --limit 5
gh api repos/{owner}/{repo}/git/refs/tags/v1 | jq .object.sha
gh api repos/{owner}/{repo}/git/refs/tags/v1.2.0 | jq .object.sha
# Both should return the same SHA after a successful release

Fix manually:

git fetch --tags
git tag -f v1 v1.2.0
git push origin v1 --force

inputs.boolean_input == true is always false​

Cause: All inputs arrive as strings. == true (non-string) never matches 'true' (string).

Fix:

if [[ "$INPUT_ENABLE_CACHE" == "true" ]]; then

Or in a uses: if: condition:

if: inputs.enable_cache == 'true'

Anti-patterns​

Anti-patternRiskFix
uses: actions/checkout@v4Tag mutable — can be rewritten to malicious codePin to full 40-char SHA
run: without shell:Error or silent wrong defaultDeclare shell: bash on every step
${{ secrets.TOKEN }} in action.ymlNot accessible — action silently gets empty stringPass as required input
run: cmd ${{ inputs.val }}Shell injection — value parsed before bashUse env: block
No id: on output-producing stepsCannot reference the outputAdd id: to every such step
Boolean compared with == trueString comparison always falseCompare with == 'true'
./scripts/run.sh relative pathBreaks when caller CWD differsUse ${{ github.action_path }}/scripts/run.sh
Forgetting chmod +xPermission denied on fresh checkoutAdd chmod +x before invoking scripts
No input validationConfusing downstream errorsValidate at top of first step, fail with ::error::
No job summaryDeveloper blind to what happenedWrite to $GITHUB_STEP_SUMMARY in every action
No timeout-minutes on network stepsHang forever on transient failuresSet timeout-minutes on every external call
Hardcoded region/account/registryAction only works for one teamAccept as required or defaulted inputs
Missing deprecation warning on renamed inputSilent break for callersEmit ::warning:: on old input, support both for one minor version

Production checklist​

Security

  • All external uses: pinned to 40-char SHA with version comment
  • shell: on every run: step
  • Secrets accepted as required: true inputs — not accessed via ${{ secrets.* }}
  • Inputs not interpolated into run: — passed through env:
  • Sensitive runtime values masked with ::add-mask:: immediately
  • File references use ${{ github.action_path }}
  • Minimum permissions: documented in README

Correctness

  • Input validation step at top — validates required fields and enum constraints
  • Every output-producing step has id:
  • All outputs: map to correct steps.<id>.outputs.<key>
  • Boolean inputs compared with == 'true'
  • Scripts have chmod +x before invocation
  • Idempotency behaviour documented

Observability

  • $GITHUB_STEP_SUMMARY written (at minimum: key inputs, outputs, status)
  • Log groups (::group::) around each logical phase
  • ::error:: / ::warning:: annotations used instead of plain echo
  • Debug mode honoured via RUNNER_DEBUG

Testing

  • Test workflow uses local path reference (./actions/…)
  • Matrix covers key input variants
  • actionlint passes with zero warnings on the workflows, and the yq checks pass on action.yml
  • Tested locally with act

Documentation

  • Every input has description: (include "Pass ${{ secrets.* }}" for secret inputs)
  • Every output has description:
  • README: inputs table with Secret? column, outputs table, permissions, idempotency, usage example
  • CHANGELOG.md present and updated

Release

  • Semver tag applied — breaking input changes bump major
  • Floating major tag (v1) updated in release workflow
  • dependabot.yml configured for github-actions ecosystem