Stop Copy-Pasting Your YAML: Use GitHub Composite Actions to DRY Up Your CI/CD
Everyone tells you that CI/CD configuration should be "explicit" and "visible," which usually ends up being a convenient excuse for copy-pasting the same 40 lines of YAML into every single repository you own. We've been told that abstraction in DevOps leads to "magic" that's hard to debug. That’s a lie—or at least, it’s a lazy half-truth. What’s actually hard to debug is finding out which of your 15 microservices is still using an outdated, vulnerable version of an action because you forgot to update one specific .github/workflows/ci.yml file during a busy Tuesday.
If you are managing more than two repositories, stop treating your GitHub Actions like static configuration. Start treating them like software.
The "Copy-Paste" Debt
You know the drill. You spend three hours perfecting a workflow that handles Docker builds, signs images with Cosign, and pushes to ECR. It’s a masterpiece. Then, you start a new project. You ctrl-c that block of YAML, ctrl-v it into the new repo, and change three variables.
Six months later, you realize your Docker build step has a caching bug. Now you have to hunt down every repository using that snippet and fix it manually. This is "YAML debt," and it scales poorly.
Enter Composite Actions
GitHub provides several ways to reuse code (Reusable Workflows, Custom Actions in JS/Docker), but Composite Actions are the sweet spot for most teams. They allow you to bundle multiple workflow steps into a single action, using the same YAML syntax you already know.
Think of it as creating a custom function for your CI pipeline.
The Anatomy of a Composite Action
A composite action lives in a file called action.yml. Unlike a standard workflow file, it uses a specific structure:
# my-repo/.github/actions/setup-python-env/action.yml
name: 'Setup Python Environment'
description: 'Installs Python, manages caching, and installs dependencies'
inputs:
python-version:
description: 'Version of Python to use'
required: true
default: '3.11'
runs:
using: "composite"
steps:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
- name: Install dependencies
shell: bash
run: pip install -r requirements.txtThe "Shell" Gotcha
Notice that shell: bash line? In a standard workflow, the shell is optional because GitHub defaults to one. In a Composite Action, it is mandatory for every run step. If you forget it, the action will fail with a cryptic error. This is because composite actions can run on different runners (Linux, Windows, macOS), and GitHub needs you to be explicit about how to execute those commands.
How to use it in your workflow
Once you've defined that action, your main workflow file goes from a bloated mess to something actually readable:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Initialize Environment
uses: ./.github/actions/setup-python-env
with:
python-version: '3.12'
- name: Run Tests
run: pytestMoving Beyond a Single Repo
Keeping actions inside the same repository is great for organization, but the real power comes from moving these actions to a centralized "DevOps" or "Actions" repository.
1. Create a repo named my-org/github-actions.
2. Move your action to my-org/github-actions/setup-python-env/action.yml.
3. Reference it across your entire organization:
- name: Initialize Environment
uses: my-org/github-actions/setup-python-env@v1
with:
python-version: '3.12'Now, when you want to improve your caching logic or add a security scanner to *every* project, you make a change in one place, cut a new tag (v1.1.0), and your teams can opt-in to the update.
When to avoid Composite Actions
I'm a fan of them, but they aren't a silver bullet.
First, debugging can be annoying. When a composite action fails, the UI doesn't always show the nested steps as clearly as a standard workflow. You often have to click through logs to figure out which specific command inside the composite action died.
Second, if you need complex logic, like conditional loops or heavy data processing, you’re better off writing a "real" Action using TypeScript. Composite actions are just wrappers for shell commands; they don't have the sophisticated error handling or performance of a compiled Node.js action.
The "DRY" Philosophy in CI/CD
The goal isn't just to have fewer lines of YAML. The goal is to reduce the cognitive load on your developers. When a developer starts a new service, they shouldn't have to worry about how to configure the OIDC provider for AWS or which flags to pass to the linter.
They should just call uses: our-org/standard-node-build@v2 and get back to writing code that actually makes the company money.
Stop copy-pasting. Start building a library. Your future self—the one trying to patch a CI vulnerability at 4:45 PM on a Friday—will thank you.