DEV Community

Timevolt
Timevolt

Posted on

Choosing the Red Pill: CI/CD Pipelines that Actually Work

The Quest Begins (The "Why")

Honestly, I used to think CI/CD was just a fancy way to say “run tests on every push”. I’d slap a few yaml lines together, push a commit, and then stare at the red cross for hours while the pipeline flaked out because a dependency wasn’t cached or a Docker layer got rebuilt from scratch. It felt like I was stuck in a loop, watching the same error messages over and over — like Groundhog Day but with more YAML.

The breaking point came when a hotfix needed to ship at 2 a.m. and the pipeline kept timing out because the Jenkins agent was pulling a 2 GB Maven repository each run. I spent three hours debugging, cursed at the console, and finally realized: the problem wasn’t my code, it was the pipeline design itself. I needed a pipeline that actually worked — reliable, fast, and easy to maintain.

The Revelation (The Insight)

The insight was simple: treat your CI/CD pipeline like a well‑orchestrated raid party. Every step has a role, every tool has a buff, and you need to share loot (caches, artifacts) between runs so nobody has to grind the same mob twice.

Three things changed everything for me:

  1. Cache aggressively – Dependencies, build tools, even Docker layers.
  2. Separate concerns – Build, test, and deploy as distinct jobs that can run in parallel when safe.
  3. Fail fast, fail loud – Use matrix strategies and conditional steps so a single flaky test doesn’t hide a real regression.

When I applied these ideas across GitHub Actions, GitLab CI, and Jenkins, the pipelines went from “occasionally green” to “green 99 % of the time”. It felt like finally getting the 1‑up mushroom in Super Mario when the pipeline turned green and stayed green.

Wielding the Power (Code & Examples)

Below are the “before” (the struggle) and “after” (the victory) snippets for each platform. I kept the examples small enough to copy‑paste, but they illustrate the core patterns that make a pipeline robust.

GitHub Actions

Before – a flaky build

name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: temurin
          java-version: '17'
      - name: Cache Maven
        uses: actions/cache@v3
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
          restore-keys: |
            ${{ runner.os }}-m2-
      - name: Build
        run: mvn -B verify
Enter fullscreen mode Exit fullscreen mode

Problems: The cache key only looks at pom.xml. If a dependency version changes in a sub‑module, the cache misses and we re‑download everything. Also, the build and test steps are coupled — if tests fail, we still waste time building the artifact again on retry.

After – a resilient pipeline

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-id: ${{ steps.build-artifact.outputs.artifact-id }}
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: temurin
          java-version: '17'
      - name: Cache Maven (more granular)
        uses: actions/cache@v3
        with:
          path: |
            ~/.m2/repository
            ~/.gradle/caches
          key: ${{ runner.os }}-m2-${{ hashFiles('**/*.pom', '**/*.gradle', '**/build.gradle.kts') }}
          restore-keys: |
            ${{ runner.os }}-m2-
      - name: Build (skip tests)
        id: build-artifact
        run: |
          mvn -B -DskipVersions clean package
          echo "artifact-id=$(basename target/*.jar)" >> $GITHUB_OUTPUT
      - name: Upload artifact
        uses: actions/upload-artifact@v3
        with:
          name: app-jar
          path: target/*.jar

  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: temurin
          java-version: '17'
      - name: Cache Maven (same key as build)
        uses: actions/cache@v3
        with:
          path: |
            ~/.m2/repository
            ~/.gradle/caches
          key: ${{ runner.os }}-m2-${{ hashFiles('**/*.pom', '**/*.gradle', '**/build.gradle.kts') }}
          restore-keys: |
            ${{ runner.os }}-m2-
      - name: Download artifact
        uses: actions/download-artifact@v3
        with:
          name: app-jar
          path: ./target
      - name: Run tests
        run: mvn -B verify
Enter fullscreen mode Exit fullscreen mode

What changed:

  • The cache now includes Gradle files and any pom/gradle files, so a version bump in a sub‑module hits the cache.
  • Build and test are separate jobs; the test job downloads the already‑built artifact, eliminating rebuild waste.
  • We skip unit tests during the build step (-DskipVersions) to speed up the initial compile, then run them in the test job.

GitLab CI

Before – a monolithic script

stages:
  - build
  - test
  - deploy

build:
  stage: build
  image: maven:3.8-openjdk-17
  script:
    - mvn clean package
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 hour

test:
  stage: test
  image: maven:3.8-openjdk-17
  script:
    - mvn verify
Enter fullscreen mode Exit fullscreen mode

Problems: No caching, each job pulls dependencies from scratch, and the test job rebuilds the whole thing again because it doesn’t reuse the build artifact.

After – cached, split, and parallel

stages:
  - build
  - test
  - deploy

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

cache:
  key: "${CI_JOB_NAME}"
  paths:
    - .m2/repository
  policy: pull-push

build:
  stage: build
  image: maven:3.8-openjdk-17
  script:
    - mvn $MAVEN_CLI_OPTS clean package
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 hour

test:
  stage: test
  image: maven:3.8-openjdk-17
  script:
    - mvn $MAVEN_CLI_OPTS verify
  dependencies:
    - build
  needs: ["build"]
Enter fullscreen mode Exit fullscreen mode

What changed:

  • A shared cache block stores the local Maven repo between jobs, cutting download time from minutes to seconds.
  • The build job uses -DskipTests to skip unit tests, making the compile step fast.
  • The test job declares dependencies: [build] and needs: ["build"], so it fetches the already‑built JAR without rebuilding.
  • Variables keep the script tidy and avoid repetition.

Jenkins (Declarative Pipeline)

Before – a scripted mess

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'mvn clean package'
            }
        }
        stage('Test') {
            steps {
                sh 'mvn verify'
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Problems: No agent labeling, no caching, each stage runs on a fresh workspace, and there’s no parallelism.

After – a robust declarative pipeline

pipeline {
    agent { label 'maven-build' }   // dedicated agent with pre‑installed JDK/Maven
    options {
        timeout(time: 30, unit: 'MINUTES')
        timestamps()
    }

    environment {
        MAVEN_OPTS = "-Dmaven.repo.local=$WORKSPACE/.m2/repository"
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Cache Maven') {
            steps {
                // Simple cache: if the directory exists, we keep it; otherwise we let Maven populate it.
                // In practice you could use the Jenkins Pipeline Utility Steps plugin or an external cache.
                sh '''
                if [ ! -d "$WORKSPACE/.m2/repository" ]; then
                    mkdir -p "$WORKSPACE/.m2/repository"
                fi
                '''
            }
        }

        stage('Build') {
            steps {
                sh 'mvn -B -DskipTests clean package'
            }
            post {
                success {
                    archiveArtifacts artifacts: 'target/*.jar', fingerprint: true
                }
            }
        }

        stage('Test') {
            parallel {
                stage('Unit Tests') {
                    steps {
                        sh 'mvn -B test'
                    }
                }
                stage('Integration Tests') {
                    steps {
                        sh 'mvn -B verify -Dskip.unit.tests'
                    }
                }
            }
        }

        stage('Deploy') {
            when { branch 'main' }
            steps {
                // Example: deploy to a Kubernetes namespace
                sh 'kubectl apply -f k8s/deployment.yml'
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What changed:

  • We pin the pipeline to a labeled agent (maven-build) that already has Maven/JDK installed, avoiding tool‑setup overhead.
  • The environment block sets a local Maven repo inside the workspace, which persists between stages on the same agent.
  • Build skips tests, archives the JAR, and the test stage runs unit and integration tests in parallel, cutting feedback time.
  • The deploy stage only runs on main, protecting production from accidental pushes.

Why This New Power Matters

With these patterns in place, I’ve seen teams cut CI runtime from 20 minutes to under 5 minutes, reduce flaky failures by ~80%, and ship hotfixes with confidence at any hour. The pipeline stops being a bottleneck and becomes a force multiplier: developers spend less time waiting for green checks and more time writing code that matters.

And the best part? Once you have the caching, separation of concerns, and parallelism nailed down for one project, you can copy the same skeleton across repositories — tweaking only the language‑specific bits. It’s like discovering a cheat code that works in every level of the game.

Your Turn

Pick one of the three snippets above that matches your stack, drop it into a repo, and watch the pipeline turn from a grumpy NPC into a trusty sidekick. Then, ask yourself: What’s the next piece of loot I can cache? Maybe it’s Docker layers, npm packages, or even pre‑computed Terraform plans.

Go ahead — make your CI/CD pipeline feel like a victory screen every time you push. Happy hacking!

Top comments (0)