Reservalia has five workflows, sixteen jobs and a problem no previous lesson has touched: the checkout + setup-node + npm ci block appears copied six times. When the cache was improved in 04-04 the change had to be applied six times, and the day somebody touches one copy and forgets the other five, the pipeline will behave differently depending on the workflow with nobody knowing why. And the worst is still to come: module 5 is going to add a mobile application and some microservices that need exactly the same thing. The central idea of this lesson is easy to state and hard to apply: the pipeline is production code and deserves the same treatment — review, versioning, refactoring and, above all, testing. We will look at GitHub Actions' three reuse mechanisms and when to use each, actually extract a composite action and a reusable workflow, rewrite ci.yml on top of them, discuss the central template repository and its risks, set readability conventions and finish with what almost nobody does: testing the pipeline before merging it.

Contents

  1. The pipeline is code, and today it is not treated as such
  2. Reservalia's real problem, told with numbers
  3. The three reuse mechanisms compared
  4. Extracting a composite action: prepare-node
  5. Extracting a reusable workflow: reusable-build-publish.yml
  6. Reservalia's ci.yml: before and after
  7. A central workflow repository and its risks
  8. Conventions for a readable pipeline
  9. Testing the pipeline
  10. Progressive migration without freezing the team
  11. Common Mistakes and Tips
  12. Exercises
  13. Conclusion

  1. The pipeline is code, and today it is not treated as such

Compare how Reservalia treats its application code and how it treats its YAML:

Practice apps/api .github/workflows/
Version control and PR review Yes Yes (CODEOWNERS)
Automatic formatting and linting Prettier + ESLint No
Automated tests Unit and integration No
Refactoring when it hurts Routine Never
Reuse of what is repeated Functions and packages Copy and paste
Deployed after verification Yes Merged and then you watch what happens

The four rows in bold are the lesson. And it is not a matter of tidiness: the pipeline is the system that decides what reaches production, so a failure in it has the same reach as a failure in the application, with the aggravating factor that the pipeline has nobody to verify it. When 04-01 diagnosed "business logic hidden in the YAML" and "the pipeline only Nuria understands" it was describing the symptoms; this lesson gives the treatment.

  1. Reservalia's real problem, told with numbers

Duplicated block Where it appears Times
checkout + setup-node + npm ci ci.yml (×4 jobs), nightly.yml, cd.yml (web job) 6
OIDC credentials + ECR login ci.yml (publish), cd.yml (×3), rollback.yml 5
docker build with registry cache ci.yml (×2), nightly.yml 3
Smoke test against /version cd.yml (×3), rollback.yml 4

Four concrete consequences, none of them theoretical. Every improvement costs ×6: the layer cache from 04-04 was applied properly in ci.yml and forgotten in nightly.yml, which still takes two minutes too long. The copies diverge: two jobs already use actions/checkout@v4 and one stayed on @v3. Review becomes useless, because a 200-line diff of repeated YAML does not get read, it gets approved. And adding a new service means copying 150 lines and hoping you have not forgotten anything. The rule that applies to code applies just as well here: on the third repetition, extract. With two copies, premature abstraction usually goes wrong; with six, the cost is already being paid every day.

  1. The three reuse mechanisms compared

Composite action Reusable workflow Matrix
What it encapsulates A sequence of steps One or more complete jobs The same job with different data
Where it lives .github/actions/<name>/action.yml or its own repository .github/workflows/<name>.yml Inside the job
Invoked with uses: inside a job uses: at job level strategy: matrix
Does it define runs-on? No: it inherits the calling job's Yes: it defines its own jobs No
Receives / returns inputs / outputs inputs, secrets / outputs Matrix values / nothing
Access to secrets Only those you pass as inputs Explicit or with secrets: inherit
When to use it Repeating steps across different jobs Repeating whole jobs or complete pipelines Repeating the same thing with different data

The rule for choosing, in one sentence: if what is repeated is steps inside a job, composite action; if it is the whole job or a complete pipeline, reusable workflow; if it is the same work with different parameters, matrix. And a limitation that surprises people and is worth knowing before designing anything: a composite action cannot define services:, so the PostgreSQL used by the tests cannot be encapsulated there; it has to stay in the job or move up to a reusable workflow.

  1. Extracting a composite action: prepare-node

We start with the most repeated part. The block that appears six times is this one:

      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: npm }
      - run: npm ci

And it becomes a local action:

# .github/actions/prepare-node/action.yml
name: Prepare Node
description: Checkout, Node from .nvmrc with npm cache, and a reproducible install.

inputs:
  fetch-depth:
    description: History depth. 0 for the full history.
    required: false
    default: '1'                       # 1

runs:
  using: composite                     # 2
  steps:
    - uses: actions/checkout@v4
      with: { fetch-depth: '${{ inputs.fetch-depth }}' }
    - uses: actions/setup-node@v4
      with: { node-version-file: .nvmrc, cache: npm }
    - name: Install dependencies
      shell: bash                      # 3
      run: npm ci
  1. inputs with a default are what make the action usable. The normal case needs no configuration; the security job, which needs the full history for gitleaks, passes fetch-depth: 0. Without that input you would have to choose between duplicating the action and always fetching the whole history, which is slow.
  2. using: composite distinguishes a steps action from a JavaScript or container action. And shell: bash is mandatory (3) in every run of a composite action: its absence is mistake number one when writing your first, and the resulting message is not especially clear.

Usage then looks like this in any job:

      - uses: ./.github/actions/prepare-node                        # 1 · the normal case
      - uses: ./.github/actions/prepare-node                        # in the security job
        with: { fetch-depth: '0' }
  1. The path starts with ./ because it is a local action, from the repository itself: GitHub downloads the repository to resolve it, so the pattern works even though the checkout is done by the action itself. If it lived in another repository it would be referenced as reservalia/actions/prepare-node@v1 and the pin-by-SHA rule from 04-03 would apply to it.

  1. Extracting a reusable workflow: reusable-build-publish.yml

The composite action solves the repeated steps, but build and publish are whole jobs repeated with variations between ci.yml and nightly.yml, and ones that module 5 will want to reuse for the mobile app and the microservices. That is a reusable workflow:

# .github/workflows/reusable-build-publish.yml
name: Reusable · build and publish

on:
  workflow_call:                       # 1 · what makes it invocable
    inputs:
      dockerfile:    { type: string,  required: true }
      repository:    { type: string,  required: true }    # e.g. reservalia/api
      publish:       { type: boolean, required: false, default: false }
    secrets:
      AWS_ROLE:      { required: true }                    # 2 · explicit, not inherit
    outputs:                                               # 3
      digest: { description: Image digest, value: '${{ jobs.build.outputs.digest }}' }

jobs:
  build:
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    permissions: { contents: read, id-token: write }
    outputs:
      digest: ${{ steps.image.outputs.digest }}
    steps:
      - uses: ./.github/actions/prepare-node
      - run: npm run build

      - uses: aws-actions/configure-aws-credentials@v4
        if: inputs.publish                                 # 4
        with: { role-to-assume: '${{ secrets.AWS_ROLE }}', aws-region: eu-west-1 }
      - uses: aws-actions/amazon-ecr-login@v2
        if: inputs.publish

      - uses: docker/setup-buildx-action@v3
      - id: image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ${{ inputs.dockerfile }}
          push: ${{ inputs.publish }}
          tags: ${{ vars.ECR_REGISTRY }}/${{ inputs.repository }}:${{ github.sha }}
          cache-from: type=registry,ref=${{ vars.ECR_REGISTRY }}/${{ inputs.repository }}:cache
          cache-to:   type=registry,ref=${{ vars.ECR_REGISTRY }}/${{ inputs.repository }}:cache,mode=max
  1. on: workflow_call turns the file into something invocable from another workflow, and typed inputs are a verified contract: passing a string where a boolean is expected fails when the file is parsed, before anything runs.
  2. Secrets are declared one by one. There is secrets: inherit, which passes all of the caller's, and it is convenient and a bad idea: it breaks the least privilege principle from 04-03 and makes it impossible to know what the workflow has access to by reading its header.
  3. The reusable workflow's outputs are fed from its jobs' outputs, and they are what lets the caller receive the digest and promote it: the mechanism from 04-01, one level up. if: inputs.publish (4) additionally allows a single workflow for two behaviours: in a pull request it builds without publishing and without requesting credentials; on main it publishes. One parameter instead of two nearly identical files.

  1. Reservalia's ci.yml: before and after

Before, the build and publish jobs took up about 45 lines with the preparation block repeated in each:

  build:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version-file: .nvmrc, cache: npm }
      - run: npm ci
      - run: npm run build
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v5
        with: { context: ., file: apps/api/Dockerfile, push: false, cache-from: '…' }

  publish:
    needs: [quality, test, build]
    if: github.ref == 'refs/heads/main'
    permissions: { contents: read, id-token: write }
    steps:
      - uses: actions/checkout@v4          # ← again
      - uses: actions/setup-node@v4        # ← again
        with: { node-version-file: .nvmrc, cache: npm }
      - run: npm ci                        # ← and credentials, ECR login,
      # … build and push duplicated from the previous job

After, the two jobs become two calls:

jobs:
  quality:
    runs-on: ubuntu-22.04
    steps:
      - uses: ./.github/actions/prepare-node           # 1
      - run: npx prettier --check .
      - run: npm run lint
      - run: npm run typecheck

  build:                                                # 2
    uses: ./.github/workflows/reusable-build-publish.yml
    with: { dockerfile: apps/api/Dockerfile, repository: reservalia/api, publish: false }
    secrets: { AWS_ROLE: '${{ secrets.AWS_ROLE_CI }}' }

  publish:
    needs: [quality, test, build, security]
    if: github.ref == 'refs/heads/main'
    uses: ./.github/workflows/reusable-build-publish.yml
    with: { dockerfile: apps/api/Dockerfile, repository: reservalia/api, publish: true }
    secrets: { AWS_ROLE: '${{ secrets.AWS_ROLE_CI }}' }
  1. One uses: replaces three steps, in the six places they used to be.
  2. A job that calls a reusable workflow has no steps and no runs-on: the called workflow defines them. It does have needs, if, with and secrets. That is what confuses people most at first, because it looks like a job and behaves like a function call.

The balance: ci.yml goes from about 180 lines to about 95, the cache is configured in one place, and adding module 5's microservice will be a four-line job with a different dockerfile and a different repository. With an honest warning: the indirection has a real readability cost — to know what build does you now have to open another file, and in the interface the jobs appear nested. Extracting something used only once makes the pipeline worse; the third-repetition rule is precisely what avoids that.

  1. A central workflow repository and its risks

When module 5 adds the mobile app and the microservices, the next question is whether those templates should live in each repository or in a central one, something like reservalia/actions.

Templates in each repository Central repository
Duplication High across repositories None
Each team's autonomy Total Limited
Propagating an improvement Repository by repository Once, for everybody
Blast radius of a mistake One repository All of them at once
Who maintains it Each team It needs an owner with a name

The risk is in the blast radius row: a change to the central template reaches every team at once and, if it breaks something, it breaks every pipeline in the company on a Tuesday morning. The mitigation is the same as for any shared dependency, as we saw in 04-02: version by tag and do not consume main.

  build:
    uses: reservalia/actions/.github/workflows/build-node.yml@v2    # 1 · a tag, not main
  1. With @v2, a change in the template does not arrive until each team bumps its reference. Consuming @main means any commit in the central repository is deployed instantly into every pipeline, with no review or testing by whoever suffers it. The practical convention is a moving major tag (v2 always pointing at the latest 2.x) to receive compatible fixes, and an explicit tag change for majors. And the central repository needs its own pipeline: tests, review and a CHANGELOG explaining what changes in each version, because it is a library even if it does not look like one.

  1. Conventions for a readable pipeline

The "the pipeline only Nuria understands" anti-pattern from 04-01 is fought with four cheap conventions. Explicit names, on jobs and on steps: name: Publish image to ECR rather than publish-2; the names are what you see in the interface when something fails and in the required checks, so a bad name costs a click every time, for ever. Centralised env in the header: region, time zone, tool versions and resource names in a single block at workflow level, rather than repeated in every step; changing region should be changing one line. Short files with a single responsibility: if a workflow goes past 150 lines or mixes two purposes, it is probably two workflows, which is the same heuristic you would apply to a code module. And comments that explain the why, never the what: # install dependencies above an npm ci is noise; what you should write is the non-obvious reason:

      # cancel-in-progress: false on main deliberately: every commit to main produces
      # a publishable artifact and cancelling would leave commits with no image (see 02-07).
      concurrency: { group: cd-main, cancel-in-progress: false }

  1. Testing the pipeline

Here is the biggest gap, and the one that gives the lesson its title. Today, the only way Reservalia has of knowing whether a pipeline change works is to merge it and watch. That is testing in production, with the peculiarity that the pipeline's production is the team's ability to deliver software: if it breaks, nobody deploys.

Level What it checks Cost When it runs
Lint and schema validation (actionlint) Syntax, expressions, references to non-existent jobs Seconds On every PR
Local execution (act) That the steps do what you think Minutes On the laptop
A test branch The full flow with real triggers One run Before merging
Low-risk service first The change against something real that is not critical One deployment When rolling out the change

actionlint is the highest-yield tool and it is added to the quality job in two lines: download the binary with the official script and run ./actionlint -color.

It catches what the editor cannot see: a malformed ${{ }} expression, a needs: [buidl] with a typo that would silently leave the job unexecuted, a non-existent runs-on, a missing shell in a composite action, and even shell errors inside run blocks, because it incorporates shellcheck. It is the difference between finding out in twenty seconds and finding out when the workflow is already on main.

act runs workflows locally in containers. It is useful for iterating on a job's logic without spending twenty minutes per attempt, and it has limits worth knowing so you do not over-trust it: it does not reproduce GitHub's environments, the protection rules, the OIDC token or services exactly. The practical rule: act is for debugging, not for validating. The test branch covers what act cannot: you create test/pipeline-templates, point the triggers at that branch and run the real flow, with its environments and its permissions, against dev resources. It is the only way to genuinely verify a change to cd.yml. And finally, rolling the change out to a low-risk service first. Once the templates are central, the new version is applied first to an internal service whose outage no customer would notice, left for a week and only then propagated. It is exactly the canary from 03-04 applied to the pipeline, and for the same reason: it is the only progressive rollout possible when the blast radius is the whole organisation.

  1. Progressive migration without freezing the team

The temptation to rewrite all five workflows in one giant pull request has to be resisted: it would be an 800-line PR impossible to review that, if it went wrong, would leave the team without a pipeline and without knowing which part failed. Reservalia's plan is five steps, none of them blocking:

  1. Add actionlint first, over the current workflows. It is the net that will make the following four steps safe, and it finds three typos on day one.
  2. Extract prepare-node and use it in a single job, quality. A twenty-line PR, reviewable, with the risk bounded to one job.
  3. Propagate it to the remaining five jobs, one or two per PR, verifying each time. This is where the accumulated divergences get fixed, such as the forgotten checkout@v3.
  4. Extract reusable-build-publish.yml and use it first in nightly.yml, the lowest-risk workflow: if it fails it blocks nobody and it gets fixed in the morning.
  5. Migrate ci.yml and cd.yml, only once the previous steps have been working for a couple of weeks.

Two principles hold the plan up. Every step leaves the system working: at no point is there a broken intermediate state, and you can stop at step 3 for a month if something urgent comes up. And you start with the lowest risk and the highest repetition, which is where the benefit arrives soonest and a mistake costs least. It is the same strategy you would apply to refactoring a production module, because the problem is exactly the same.

Common Mistakes and Tips

Mistake 1: extracting on the first repetition. An abstraction built on a single case ends up full of parameters to accommodate the second; wait for the third. Mistake 2: forgetting shell: bash in a composite action's run steps, with an unclear error message.

Mistake 3: using secrets: inherit for convenience. It passes all of the caller's secrets and breaks least privilege; declare the ones you need. Mistake 4: consuming central templates from main, so somebody else's commit changes everybody's pipeline with no warning. Mistake 5: putting steps in a job that calls a reusable workflow: it is not a normal job, it is a call, and it fails when the file is parsed.

Mistake 6: over-abstracting until understanding why the pipeline failed requires opening four files; sometimes ten duplicated, clear lines are better than a template with nine parameters. Mistake 7: merging a pipeline change you have never run, which is testing in production with the whole team as the guinea pig. Mistake 8: rewriting all five workflows in one PR, impossible to review and to diagnose if something goes wrong. Tip 1: add actionlint today, even if you are not going to refactor anything; it is the best benefit/effort ratio in the whole lesson. Tip 2: version the templates by tag and publish a CHANGELOG. Tip 3: document in the templates' README how they are used and how they are tested, or you will be back to a system only one person understands. Tip 4: apply the same reviews to the pipeline as to the code, starting with CODEOWNERS over .github/.

Exercises

Exercise 1

For these three cases, decide which reuse mechanism you would use and justify it: (a) four jobs repeat the same five preparation steps; (b) three different repositories need the same complete image build-and-publish pipeline; (c) the same test suite has to run against Node 18, 20 and 22.

Exercise 2

A team extracts its templates into company/workflows and every repository consumes them with @main. On a Tuesday morning somebody merges a change to the template and every pipeline in the company fails. Explain what failed in the design, which practices from this lesson would have prevented it and how you would fix it, distinguishing the immediate response from the structural one.

Exercise 3

Design the testing strategy for a change to cd.yml that adds a cosign signature verification before deploying to prod. State what you would check at each level, in what order and what you would do if something fails at the last one.

Solutions

Solution 1. (a) Composite action. What is repeated is steps inside jobs that remain different from each other; a composite action is inserted with a uses: and inherits the calling job's runs-on, which is exactly what you want. A reusable workflow would be excessive, because it would force each job to become a call and the rest of its content to be redefined.

(b) Reusable workflow, hosted in a central repository and consumed by tag. What is repeated is a whole pipeline with its jobs, its runs-on and its permissions, and a composite action cannot encapsulate that. The inputs parameterise what changes — dockerfile, image repository, whether it publishes — and the secrets are declared explicitly. (c) Matrix. There is nothing to extract: it is the same job run three times with a different value. strategy: matrix: { node: [18, 20, 22] } with fail-fast: false so you see all three results. The aggregator job from 04-04 is also advisable, because required checks are configured by name and the names include the matrix value.

Solution 2. What failed: consuming a shared dependency from its development branch. @main means any commit in the central repository goes into production — the production of every pipeline — with no review, no tests and no way for consumers to choose when. It is exactly what in 04-02 would be having no lockfile: every dependency on latest. What would have prevented it: (1) versioning by tag (@v2), so a change in the central repository does not arrive until each team bumps its reference; (2) the central repository having its own pipeline with actionlint, schema validation and tests, plus mandatory review with CODEOWNERS; (3) testing the change in a low-risk repository before publishing the tag, which is the canary from section 9; and (4) a CHANGELOG letting consumers know what changes before they bump the version.

Immediate response: revert the commit in the central repository, which restores every pipeline at once, and only then investigate. The temptation to fix forwards with the whole team blocked is the classic incident-management mistake, and it applies here just as it does in production: revert first, diagnose afterwards. Structural response: publish a v1 tag with the last known good state, migrate every repository from @main to @v1 and, from there, adopt the versioning cycle with tests.

Solution 3. Five levels, in order of increasing cost:

  1. actionlint locally and on the PR. It catches malformed expressions and shell errors in cosign's run block. Seconds, and it rules out half the possible mistakes.
  2. Manual verification of the command outside the pipeline. Run cosign verify from a laptop against a real already-published image, checking that it fails with an unsigned image. This is key: a verification that always passes verifies nothing, so you have to test the negative case before the positive one.
  3. act to iterate on the step's logic, knowing it will reproduce neither OIDC nor the environments: it is for refining the script, not for signing it off.
  4. A test branch with a real deployment to dev, the first level that exercises the full flow with real permissions and environments. Both paths are checked: a signed image, which deploys, and an unsigned image, which is stopped; without the negative case, the gate is not tested.
  5. Apply the change first to dev and staging for a week, and only afterwards to prod. Signature verification is a new gate, and its most likely failure mode is not letting something bad through, but blocking a legitimate deployment on a day you need to deploy in a hurry.

If something fails at the last level — the verification blocks a legitimate deployment in prod — there are two responses and only one is right. The wrong one is disabling the verification "temporarily", because that disabling is never reversed. The right one is to treat it as an incident: use rollback.yml to go back to the previous version if it is urgent, and diagnose why the signature was not valid — the OIDC role changed, the signer's identity does not match the regular expression, the image was republished unsigned — in order to fix the cause. And to prepare for it in advance: document in SECURITY.md who can authorise an exception, with what justification and with what review date, so that decision is not improvised at eleven at night.

Conclusion

Reservalia's pipeline has stopped being six copies of the same YAML. A composite action, .github/actions/prepare-node, concentrates the checkout, the Node installation from .nvmrc and the npm ci with cache, parameterised with fetch-depth so the security job gets the full history without duplicating anything. A reusable workflow, .github/workflows/reusable-build-publish.yml, encapsulates building and publishing with typed inputs, secrets declared one by one — never inherit — and an output with the digest, so that a single file covers the "build without publishing" case of pull requests and the "publish" case of main. ci.yml has gone from 180 lines to 95, the cache is configured in one place, and module 5's mobile app and microservices will be four lines each. With the rule that prevents excess: on the third repetition you extract, and an abstraction used only once makes the pipeline worse. And we know how to choose the mechanism — composite action for steps, reusable workflow for jobs or complete pipelines, matrix for the same work with different data — when a central repository pays off and what its real risk is: that its blast radius is the whole organisation, which is why it is consumed by tag and never from main, with its own pipeline, its review and its CHANGELOG. And we have conventions that stop us returning to the pipeline only one person understands: explicit names, centralised env, short files with a single responsibility and comments that explain the why.

What changes most, though, is the last part. Reservalia no longer merges pipeline changes to see what happens: it puts them through actionlint on every pull request — which found three typos on day one, among them a misspelled needs that silently left a job unexecuted — iterates on them locally with act knowing it is for debugging and not for validating, runs them on a test branch with real triggers and environments, and applies them first to the lowest-risk service. All of it by progressive migration, in five steps that leave the system working at each one, without freezing anybody's work. The fourth front remains, and it is the one that breaks the most deployments. Everything built over three modules rests on a property the database schema does not have: an artifact is immutable and can be swapped for the previous one in four minutes, but a deleted column does not come back, and the rollback.yml from 03-05 does not know how to undo a DROP COLUMN. The next lesson, Databases in the Pipeline: Safe Migrations, tackles shared state: versioned migrations executed from the pipeline and never from a laptop, the expand and contract pattern developed step by step over a real Reservalia case, the classification of schema changes into safe, dangerous and forbidden while live, the locks an ALTER TABLE can cause in production, and why customer data must never end up in staging.

CI/CD Course: Continuous Integration and Deployment

Module 1: Introduction to CI/CD

Module 2: Continuous Integration (CI)

Module 3: Continuous Deployment (CD)

Module 4: Advanced CI/CD Practices

Module 5: Implementing CI/CD in Real Projects

Module 6: Tools and Technologies

Module 7: Practical Exercises

Module 8: Additional Resources

© Copyright 2026. All rights reserved