DEV Community

Timevolt
Timevolt

Posted on

CI/CD Pipelines That Actually Work: A Journey Through the Matrix

The Quest Begins (The "Why")

Honestly, I used to stare at my CI/CD yaml files and feel like Neo stuck in the loading screen—except the only thing loading was my frustration. Every push triggered a cascade of jobs that duplicated the same steps over and over: lint, test, build, deploy. Change a single dependency? I’d have to edit three separate files (GitHub Actions, GitLab CI, and a Jenkinsfile) and pray I didn’t miss a typo. The worst part was the “works on my machine” syndrome that showed up only after a production deploy, leaving me scrambling at 2 a.m. with a cold brew and a sinking feeling that I’d just stepped into a glitch.

That moment—when a hotfix broke because I’d forgotten to update the test matrix in one pipeline—was my dragon. I realized I wasn’t fighting a tooling problem; I was fighting a process problem. If I could get the pipelines to behave like a single, well‑orchestrated system instead of three jealous siblings, I’d reclaim hours every week and sleep a lot better.

The Revelation (The Insight)

The treasure I uncovered wasn’t a new CI/CD platform; it was the idea of treating your pipeline definition as code you can reuse, version, and test just like your application. Think of it as extracting the common “spell” that does lint‑test‑build‑deploy and storing it in a grimoire (a shared workflow or template) that each CI system can call.

Three patterns made the biggest difference:

  1. Reusable workflows / templates – define the core steps once, then invoke them from each repo.
  2. Matrix strategies – let the platform handle cross‑version testing without copy‑pasting dozens of near‑identical jobs.
  3. Parameterized inputs – pass things like node version, Docker image tag, or environment name as variables so the same workflow adapts to many contexts.

When I applied these, the pipelines stopped feeling like a brittle Rube Goldberg machine and started feeling like a reliable conveyor belt—smooth, predictable, and actually fun to watch.

Wielding the Power (Code & Examples)

Below is the “before” – a naïve GitHub Actions file that repeats the same steps for three Node versions. After that, I’ll show the “after” using a reusable workflow and a matrix. I’ll then give the equivalent snippets for GitLab CI and Jenkins so you can see how the same idea translates across platforms.

🚧 The Struggle (Before)

# .github/workflows/ci.yml – the painful version
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-node12:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [12.x]
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

  test-node14:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

  test-node16:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16.x]
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Three nearly identical jobs → any change means three edits.
  • No clear separation of concerns → the workflow file is hard to read.
  • If we wanted to add a lint step or a Docker build, we’d have to copy‑paste it three more times.

✨ The Victory (After)

First, create a reusable workflow that contains the core steps. I keep it in a .github/workflows/reusable-test.yml file at the root of the repo (or in a separate “shared” repo if you want true cross‑project reuse).

# .github/workflows/reusable-test.yml
name: Reusable Test

on:
  workflow_call:
    inputs:
      node-version:
        description: 'Node.js version to use'
        required: true
        type: string

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm test
      # Add lint, build, etc., here once and it applies to every caller
Enter fullscreen mode Exit fullscreen mode

Now the main CI file becomes a thin dispatcher that uses a matrix to call the reusable workflow for each version:

# .github/workflows/ci.yml – the clean version
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-matrix:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [12.x, 14.x, 16.x]
    steps:
      - uses: actions/checkout@v3
      - name: Call reusable test
        uses: ./.github/workflows/reusable-test.yml
        with:
          node-version: ${{ matrix.node-version }}
Enter fullscreen mode Exit fullscreen mode

Why this feels like a win:

  • The reusable workflow is a single source of truth. Update the test command once, and every job gets it.
  • The matrix lives in the caller, keeping the decision of which versions to test separate from how we test them.
  • Adding a new step (say, npm run lint) is a one‑liner inside the reusable file—no more copy‑paste torture.

🎯 GitLab CI – Same Idea, Different Syntax

Before (the copy‑paste hell):

# .gitlab-ci.yml
test:node12:
  image: node:12
  script:
    - npm ci
    - npm test

test:node14:
  image: node:14
  script:
    - npm ci
    - npm test

test:node16:
  image: node:16
  script:
    - npm ci
    - npm test
Enter fullscreen mode Exit fullscreen mode

After (using a hidden template and parallel matrix):

# .gitlab-ci.yml
.test_template: &test_template
  script:
    - npm ci
    - npm test

test:
  image: node:$NODE_VERSION
  <<: *test_template
  parallel:
    matrix:
      - NODE_VERSION: [12, 14, 16]
Enter fullscreen mode Exit fullscreen mode

If you prefer a truly reusable file, GitLab’s include feature lets you point to a separate .gitlab-ci-test.yml that holds the script block, and then you just include it in each job. The principle stays the same: define the steps once, invoke them many times.


🛠️ Jenkins – Declarative Pipeline with Shared Library

Before (the long Jenkinsfile):

pipeline {
    agent any
    stages {
        stage('Test Node 12') {
            agent { docker { image 'node:12' } }
            steps {
                sh 'npm ci'
                sh 'npm test'
            }
        }
        stage('Test Node 14') {
            agent { docker { image 'node:14' } }
            steps {
                sh 'npm ci'
                sh 'npm test'
            }
        }
        stage('Test Node 16') {
            agent { docker { image 'node:16' } }
            steps {
                sh 'npm ci'
                sh 'npm test'
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

After (using a shared library function):

First, add a function to vars/testNode.groovy in your shared library repo:

// vars/testNode.groovy
def call(String version) {
    docker.image("node:${version}").inside {
        sh 'npm ci'
        sh 'npm test'
    }
}
Enter fullscreen mode Exit fullscreen mode

Then the Jenkinsfile becomes succinct:

// Jenkinsfile
pipeline {
    agent any
    stages {
        stage('Test Matrix') {
            parallel {
                stage('Node 12') { steps { testNode '12' } }
                stage('Node 14') { steps { testNode '14' } }
                stage('Node 16') { steps { testNode '16' } }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the actual test steps live in one place (testNode.groovy). Add a lint step? Edit the shared function once, and every pipeline that pulls the library gets it instantly.

Why This New Power Matters

By treating pipeline definitions as reusable, version‑controlled code, you’ve turned a maintenance nightmare into a lever.

  • Speed: Adding a new language version or a new static‑analysis tool is a matter of editing one file, not five.
  • Reliability: Fewer places to copy‑paste means fewer opportunities for a stray typo to slip in and break a release.
  • Collaboration: Teams can share a library of workflows (or templates) across repos, establishing a consistent baseline for quality without micromanaging each project.
  • Joy: Watching a pipeline spin up, run its matrix, and report success feels like watching a well‑choreographed dance instead of a chaotic mosh pit.

You’ve gone from “I hope this works” to “I know this works”—and that confidence is infectious.

Your Turn – The Challenge

Pick one of your existing CI files (GitHub Actions, GitLab CI, or Jenkins). Identify the repeated block—maybe it’s the npm ci && npm test step, or a Docker build/push sequence. Extract that block into a reusable workflow/template/shared‑library function, then replace the duplicates with a matrix or loop that calls it.

When you see the green checkmarks appear across all versions with a single source of truth, drop a comment below sharing what you extracted and how much time you saved. Let’s keep the quest going—because the best pipelines aren’t just built; they’re crafted together. Happy hacking!

Top comments (0)