GitHub Actions in Depth

Reusable Workflows & Composite Actions

22 min Lesson 6 of 30

Reusable Workflows & Composite Actions

As a platform grows, the same CI/CD patterns repeat across dozens of repositories: lint, test, build Docker image, push to registry, deploy to staging. Copy-pasting workflow YAML is the fastest path to an unmaintainable mess — a security patch or runner upgrade has to be applied in 40 places. GitHub Actions provides two orthogonal mechanisms to break that cycle: reusable workflows and composite actions. Knowing which tool fits which problem, and how to version both correctly, is what separates a professional CI platform from a pile of duplicated YAML.

Reusable Workflows — workflow_call

A reusable workflow is a normal workflow file that declares on: workflow_call as one of its triggers. Any other workflow can then invoke it as if it were a single job — the callee runs in full isolation with its own jobs, runners, and environment. This is the right tool when you want to share entire pipeline stages including multi-job logic, environment gates, and OIDC credentials.

Reusable workflows support typed inputs (string, boolean, number, choice) and secrets passing, and can emit outputs for the caller to consume. Inputs are declared under on.workflow_call.inputs; secrets under on.workflow_call.secrets.

# .github/workflows/reusable-docker-build.yml (in the SAME or a SHARED repo) name: Reusable — Docker Build & Push on: workflow_call: inputs: image_name: description: "Docker image name (without registry prefix)" required: true type: string tag: description: "Image tag (e.g. git SHA or semver)" required: false default: "latest" type: string push: description: "Push image to registry after build" required: false default: true type: boolean secrets: registry_token: required: true outputs: image_digest: description: "SHA256 digest of the pushed image" value: ${{ jobs.build.outputs.digest }} jobs: build: runs-on: ubuntu-24.04 outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.registry_token }} - name: Build and push id: push uses: docker/build-push-action@v5 with: context: . push: ${{ inputs.push }} tags: ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:${{ inputs.tag }} cache-from: type=gha cache-to: type=gha,mode=max

A caller in any repository in the same organization invokes this with the uses key at the job level — not the step level:

# .github/workflows/ci.yml (in any consumer repo) name: CI on: push: branches: [main] jobs: test: uses: my-org/.github/.github/workflows/reusable-docker-build.yml@v2 with: image_name: api-service tag: ${{ github.sha }} push: true secrets: registry_token: ${{ secrets.GHCR_TOKEN }} deploy: needs: test runs-on: ubuntu-24.04 steps: - name: Use image digest from build run: echo "Deploying ${{ needs.test.outputs.image_digest }}"
The uses key on a job accepts three reference forms: org/repo/.github/workflows/file.yml@ref for cross-repo, ./.github/workflows/file.yml for same-repo (no @ref), and the special org/.github repository as a shared organization-level store. Always pin cross-repo calls to a tag or SHA — never use a mutable branch like @main in production; a breaking change there will silently break every caller on next run.

Composite Actions

A composite action is a reusable sequence of steps packaged in an action.yml file. Unlike reusable workflows, composite actions run as steps inside an existing job — they share the runner, the workspace, and all environment variables of the calling job. This makes them the right abstraction for encapsulating a repeatable step sequence (install dependencies, configure a tool, run a linter) without the overhead of a separate job.

Create the action in a repository under any path (conventionally .github/actions/<name>/action.yml for same-repo actions, or as the root action.yml for a standalone action repo).

# .github/actions/setup-node-pnpm/action.yml name: "Setup Node + pnpm" description: "Installs the correct Node version, enables pnpm via corepack, and restores the pnpm store cache." inputs: node-version: description: "Node.js version to install" required: false default: "20" pnpm-version: description: "pnpm version to enable via corepack" required: false default: "9" outputs: cache-hit: description: "Whether the pnpm store cache was restored" value: ${{ steps.cache.outputs.cache-hit }} runs: using: "composite" steps: - name: Setup Node ${{ inputs.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - name: Enable pnpm ${{ inputs.pnpm-version }} via corepack shell: bash run: | corepack enable corepack prepare pnpm@${{ inputs.pnpm-version }} --activate - name: Restore pnpm store cache id: cache uses: actions/cache@v4 with: path: ~/.local/share/pnpm/store key: pnpm-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | pnpm-${{ runner.os }}-${{ inputs.node-version }}- - name: Install dependencies shell: bash run: pnpm install --frozen-lockfile

Every run step inside a composite action must declare a shell — GitHub cannot infer it from context the way it can in a regular workflow. Forgetting this is the most common authoring error.

Composite actions run inside the calling job and therefore inherit its secrets automatically — you do not pass secrets via inputs. However, this also means a compromised composite action can read all secrets on the runner. Apply the same trust model you use for third-party actions: pin to a full SHA (uses: my-org/.github/actions/setup-node-pnpm@abc1234), audit changes in code review, and never use a mutable tag in a security-sensitive pipeline.

Architecture — When to Use Which

Reusable Workflow vs Composite Action decision diagram Need separate jobs / env gates? YES Reusable Workflow NO Composite Action Reusable Workflow • Runs in own jobs • Isolated runners • Env / approval gates • Typed secrets input • Cross-repo sharing • Output to caller Composite Action • Steps inside a job • Shares runner + workspace • Inherits env & secrets • Lightweight, fast • action.yml in any repo • No separate billing unit
Decision guide: choose Reusable Workflow for multi-job isolation and environment gates; choose Composite Action for step-level encapsulation within a job.

Organization-Wide Standards with a Central Workflow Repository

Large engineering organizations store all canonical reusable workflows and composite actions in a single shared repository — by convention named .github under the organization (e.g. my-org/.github). GitHub gives this repo two special powers: its .github/workflows/ directory is the default look-up path for workflow_call references using the short form, and its workflow-templates/ directory populates the "Actions" new-workflow UI for every repository in the org.

Pair the shared repo with required workflows (configured in Organization Settings → Actions → Required workflows). A required workflow runs on every pull request in every repository in the org — regardless of what the repo's own CI does. This is how platform teams enforce security scans, license checks, or SBOM generation without asking every dev team to opt in.

Version your shared workflows with semantic Git tags (v1, v1.2, v1.2.3). Callers pin to @v1 (a lightweight tag you move forward on patch releases), giving you the ability to push non-breaking improvements without callers updating their YAML. Introduce breaking changes under @v2 and maintain both in parallel during a migration window — the same model used by actions/checkout and actions/setup-node.

Common Failure Modes

  • Missing shell on composite run steps — the action fails with a cryptic error. Every run inside runs.using: composite needs shell: bash (or pwsh, python, etc.).
  • Passing secrets as inputs to composite actions — secrets auto-inherit; passing them as inputs exposes the values in the workflow summary log. Pass secrets only to reusable workflows via on.workflow_call.secrets.
  • Circular calls — a reusable workflow calling itself (directly or via a chain) is a hard error. GitHub detects and blocks cycles up to three levels deep.
  • Depth limit — reusable workflows can be nested up to four levels deep. Hitting this usually signals the abstraction is too fine-grained; consolidate.
  • Mutable ref on cross-repo call@main on a caller means a force-push or accidental commit to the shared repo immediately breaks all callers. Always use a tag or SHA.