DEV Community

Timevolt
Timevolt

Posted on

The Matrix of CI/CD: Building Pipelines That Actually Work

The Quest Begins (The "Why")

I still remember the first time I tried to automate a release. I had a shiny new feature branch, a handful of unit tests, and a dream of pushing to production with a single click. Instead, I got a snowflake of manual steps: SSH into a server, pull the latest code, run npm install, hope the environment hadn’t drifted, then cross my fingers while the build chugged along for twenty minutes. When it finally failed because a dependency version had changed overnight, I felt like I was stuck in a never‑ending loop of “fix it, push, wait, fail.”

That frustration was the dragon I needed to slay. I wanted a pipeline that was repeatable, fast, and transparent—something I could trust to give me instant feedback every time I pushed a commit. If you’ve ever stared at a spinning CI icon wondering whether the green check would ever appear, you know exactly what I mean.

The Revelation (The Insight)

The breakthrough came when I stopped treating CI as a series of ad‑hoc scripts and started seeing it as code that describes the entire build, test, and deploy process. Once I embraced that mindset, a few patterns emerged that made all the difference:

  1. Idempotent steps – each job should produce the same output given the same input, no matter how many times it runs.
  2. Caching dependencies – reinstalling npm packages or Maven artifacts on every run is a waste of time.
  3. Parallelism where it makes sense – linting, unit tests, and integration tests can often run side‑by‑side.
  4. Clear separation of concerns – lint, build, test, and deploy are distinct stages; mixing them hides failures.

When I applied these ideas, the pipeline stopped feeling like a mysterious black box and started feeling like a reliable spell I could cast whenever I needed.

Wielding the Power (Code & Examples)

Below are the “before” and “after” snippets for three popular CI systems. I’ll point out the common traps (the traps) and show how to avoid them.

GitHub Actions – The Before (a fragile workflow)

name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Node
        run: |
          curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
          sudo apt-get install -y nodejs
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
Enter fullscreen mode Exit fullscreen mode

Traps:

  • Installing Node from scratch each run adds minutes.
  • No caching of node_modules means npm ci re‑downloads everything.
  • If the test suite grows, the job runs sequentially with linting and building, slowing feedback.

GitHub Actions – The After (a solid, repeatable workflow)

name: CI

on: [push, pull_request]

jobs:
  lint-test-build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x]
    steps:
      - uses: actions/checkout@v3

      # Cache node_modules between runs
      - name: Cache Node modules
        uses: actions/cache@v3
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      # Lint, test, and build run in parallel steps
      - name: Lint
        run: npm run lint

      - name: Test
        run: npm test

      - name: Build
        run: npm run build
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • actions/cache saves the npm folder, cutting install time from ~90 seconds to ~10 seconds on subsequent runs.
  • actions/setup-node gives us a pre‑built Node binary, removing the manual compile step.
  • The strategy.matrix lets us easily test multiple Node versions later without duplicating code.
  • Separating lint, test, and build into distinct steps makes failures obvious and allows GitHub to run them in parallel if you add needs: relationships.

GitLab CI – The Before (a monolithic script)

stages:
  - build
  - test
  - deploy

build_job:
  stage: build
  script:
    - apt-get update && apt-get install -y openjdk-11-jdk
    - ./gradlew build

test_job:
  stage: test
  script:
    - ./gradlew test

deploy_job:
  stage: deploy
  script:
    - scp build/libs/app.jar user@server:/opt/app/
    - ssh user@server "systemctl restart app"
Enter fullscreen mode Exit fullscreen mode

Traps:

  • Re‑installing Java on every pipeline wastes time.
  • No artifacts are passed between stages, so the build job recompiles everything for test and deploy.
  • Hard‑coded server credentials (if they were in the script) would be a security nightmare.

GitLab CI – The After (using caching and artifacts)

stages:
  - build
  - test
  - deploy

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"

cache:
  key: "${CI_JOB_NAME}"
  paths:
    - .m2/repository
    - build/

build_job:
  stage: build
  image: maven:3.9-eclipse-temurin-11
  script:
    - mvn -B compile
  artifacts:
    paths:
      - target/
    expire_in: 1 hour

test_job:
  stage: test
  image: maven:3.9-eclipse-temurin-11
  script:
    - mvn -B test
  dependencies:
    - build_job
  artifacts:
    reports:
      junit: target/surefire-reports/*.xml
    expire_in: 1 hour

deploy_job:
  stage: deploy
  image: alpine:latest
  script:
    - apk add --no-cache openssh
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
    - scp target/app.jar $DEPLOY_USER@$DEPLOY_HOST:/opt/app/
    - ssh $DEPLOY_USER@$DEPLOY_HOST "systemctl restart app"
  only:
    - main
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The cache directive preserves the Maven local repo between pipelines, slashing download times.
  • artifacts pass the compiled JAR from build to test and deploy, eliminating redundant work.
  • Using a dedicated Maven image guarantees a consistent JDK without manual installs.
  • SSH keys are injected via protected CI variables (SSH_PRIVATE_KEY), keeping secrets out of the repo.

Jenkins (Declarative Pipeline) – The Before (a scripted mess)

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'npm install'
                sh 'npm run build'
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
        stage('Deploy') {
            steps {
                sh 'scp build/app.jar user@server:/opt/app/'
                sh 'ssh user@server "systemctl restart app"'
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Traps:

  • No agent label means the job could run on any node, potentially with different tools.
  • No caching or artifact handling, so npm install runs fresh each time.
  • Hard‑coded host and credentials (if they were in the script) are a security risk.

Jenkins (Declarative Pipeline) – The After (clean, cached, secure)

pipeline {
    agent { label 'node' } // ensure we run on a node with Node.js pre‑installed
    options {
        timeout(time: 20, unit: 'MINUTES')
        timestamps()
    }
    environment {
        // Credentials are injected via Jenkins credentials store
        SSH_KEY = credentials('ssh-deploy-key')
    }
    stages {
        stage('Prepare') {
            steps {
                // Cache node_modules across builds using the workspace
                dir('.npm') {
                    // nothing – just ensures the dir exists
                }
            }
        }
        stage('Build') {
            steps {
                sh '''
                npm ci
                npm run build
                '''
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
            post {
                always {
                    junit '**/test-results/*.xml'
                }
            }
        }
        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                // Use ssh-agent to safely use the injected key
                sshagent([SSH_KEY]) {
                    sh '''
                    scp build/app.jar ${env.DEPLOY_USER}@${env.DEPLOY_HOST}:/opt/app/
                    ssh ${env.DEPLOY_USER}@${env.DEPLOY_HOST} "systemctl restart app"
                    '''
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The agent { label 'node' } guarantees a consistent environment with Node.js already installed, removing the install step.
  • sshagent lets us use a stored credential without ever writing it to disk in plain text.
  • The timeout and timestamps options give us visibility and prevent runaway jobs.
  • The when block restricts deployment to the main branch, protecting production from accidental pushes.

Why This New Power Matters

Once I moved from fragile, one‑off scripts to these structured, cached, and parallel pipelines, my feedback loop collapsed from twenty minutes to under two. I could push a feature branch, watch the lint, unit tests, and integration tests run in parallel, and get a green check before I even finished my coffee.

That speed gave me the confidence to deploy multiple times a day, to experiment with feature flags, and to refactor without fear. The pipeline stopped being a gatekeeper and became a trustworthy teammate that shouted “something’s wrong!” the instant a test failed.

Beyond speed, the reliability meant fewer “works on my machine” arguments. Because every step declared its dependencies (Node version, JDK, cached artifacts) the same way in every environment, the build behaved identically on my laptop, in the CI runner, and on the production server.


Your Turn: Start Your Own Quest

Pick one of the pipelines above that matches your stack, drop it into your repo, and add a caching step if it doesn’t already have one. Watch how the install time shrinks, then try splitting a long test suite into parallel jobs.

What’s the biggest time sink you’ve seen in your own CI? How would you tackle it with the patterns we discussed? Share your results in the comments—I’m excited to hear about your victories! 🚀

Top comments (0)