Two remaining patterns before we enter GitOps territory: composite actions (step-level reuse) and mono-repo path filtering (build only what changed).
Composite Actions: Reuse at the Step Level
Every job starts the same: checkout → setup-node → npm ci. That's 3 steps repeated in every job. A composite action collapses them into one:
Create .github/actions/setup/action.yml
name: 'Setup Project'
description: 'Checkout + Node + install deps'
inputs:
node-version:
default: '20'
working-directory:
default: '.'
runs:
using: 'composite'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- shell: bash
working-directory: ${{ inputs.working-directory }}
run: npm ci
Use it
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup # Replaces 3 steps!
- run: npm test
Mono-Repo Pipelines: Build Only What Changed
Path-Based Triggering
# .github/workflows/ci-api.yml
name: API CI
on:
push:
paths:
- 'services/api/**'
- 'packages/shared/**' # Rebuild if shared code changes too!
- '.github/workflows/ci-api.yml'
jobs:
ci:
uses: ./.github/workflows/reusable-ci.yml
with:
working-directory: services/api
Dynamic Detection with dorny/paths-filter
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- 'services/api/**'
- 'packages/shared/**'
web:
- 'services/web/**'
- 'packages/shared/**'
ci-api:
needs: changes
if: needs.changes.outputs.api == 'true'
uses: ./.github/workflows/reusable-ci.yml
with: { working-directory: services/api }
ci-web:
needs: changes
if: needs.changes.outputs.web == 'true'
uses: ./.github/workflows/reusable-ci.yml
with: { working-directory: services/web }
Architect Decision: Mono-Repo vs Multi-Repo Pipelines
| Mono-Repo | Multi-Repo | |
|---|---|---|
| Pipeline per service | Path-filtered in one repo | Each repo has its own workflows |
| Shared code | packages/ — path triggers dependents | Published as npm packages |
| Reusable workflows | ./.github/workflows/reusable-*.yml | Separate repo or org-level |
| Complexity | Higher (change detection logic) | Lower per repo, more repos to manage |
| Best for | Tightly coupled services, small teams | Independent services, larger orgs |
🧠 Recall Check
- What's the file extension and key property that makes a composite action?
- In a mono-repo, what happens when you change
packages/shared/? - What's the difference between
uses: ./.github/actions/setupanduses: ./.github/workflows/reusable-ci.yml?
Reveal answers
action.ymlwithruns: using: 'composite'. It defines reusable steps (not jobs).- All services that depend on shared should rebuild. Include
'packages/shared/**'in each service's path filter to trigger their pipelines. - The first is a composite action (a set of steps, used within a job). The second is a reusable workflow (a complete workflow with its own jobs, used as a top-level job in the caller).
You've now completed the push-based CI/CD toolkit. You can build, test, containerize, deploy, and scale pipelines across services. Next: we enter GitOps territory — a fundamentally different (and better) delivery model for Kubernetes.
Next lesson: GitOps Principles — the mental model shift from push to pull.