DEV Community

Timevolt
Timevolt

Posted on

CI/CD Pipelines That Actually Work: Lessons from The Matrix

The Quest Begins (The “Why”)

Honestly, I used to stare at my CI/CD yaml files like they were ancient runes. Every push felt like a gamble: “Will the build pass this time?” I’d spend Friday nights hunting down a missing node_modules cache in Jenkins, only to realize the agent had run out of disk space because I’d forgotten to add a cleanup step. The pain was real, and the feedback loop was slower than a dial‑up modem.

I kept asking myself: Why does this feel like wrestling a dragon every time I want to ship a feature? The answer was simple—I hadn’t yet found a pipeline that just worked out of the box. I wanted something that gave me confidence, not anxiety. So I embarked on a quest to compare the three big contenders: GitHub Actions, GitLab CI, and good ol’ Jenkins. Spoiler: the treasure wasn’t in the tool itself, but in how you shape the pipeline around your team’s flow.

The Revelation (The Insight)

The big “aha!” moment came when I stopped treating CI/CD as a one‑size‑fits‑all script and started seeing it as a contract between my code and my environment. The contract says:

  1. Every commit gets a clean slate.
  2. Dependencies are restored, not guessed.
  3. Tests run in parallel, not sequentially.
  4. Artifacts are published only if the gate passes.

When I wrote that contract down, the yaml stopped looking like magic incantations and started looking like a checklist. The tools differ in syntax, but the underlying principles are the same.

Here’s the secret: cache wisely, fail fast, and keep the pipeline short enough to give you feedback before you’ve even finished your coffee.

Wielding the Power (Code & Examples)

Below are three pipelines—one for each platform—that embody the contract above. I’ll first show a “struggle” version (the common pitfalls) and then the victorious version.

1. GitHub Actions – The Struggle

name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install deps
        run: npm install   # <-- no cache, reinstalls every time
      - name: Run tests
        run: npm test
Enter fullscreen mode Exit fullscreen mode

Problems: No caching → slow builds; no explicit node_modules restore; if a test flakes, the whole job fails without retry logic.

2. GitHub Actions – The Victory

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    caching:
      # Cache node_modules based on lockfile
      - key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-node-

    steps:
      - uses: actions/checkout@v3
      - 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: Install deps
        run: npm ci   # ci uses lockfile, faster & deterministic
      - name: Run tests
        run: npm test
        continue-on-error: false   # fail fast
      - name: Upload coverage
        if: success()
        uses: actions/upload-artifact@v3
        with:
          name: coverage
          path: coverage/
Enter fullscreen mode Exit fullscreen mode

Why it works: We lock down dependencies with npm ci, cache based on the lockfile, and upload an artifact only on success. Feedback is quick, and the pipeline is reproducible.

3. GitLab CI – The Struggle

stages:
  - build
  - test

build_job:
  stage: build
  script:
    - npm install
    - npm run build

test_job:
  stage: test
  script:
    - npm test
Enter fullscreen mode Exit fullscreen mode

Problems: Each job starts with a fresh runner, so npm install runs twice; no caching; if build_job fails, test_job still runs because we didn’t use needs: or allow_failure: correctly.

4. GitLab CI – The Victory

stages:
  - prepare
  - build
  - test

variables:
  NODE_ENV: test
  # Cache based on lockfile
  cache:
    key: "${CI_COMMIT_REF_SLUG}-${CI_JOB_NAME}-${hash:package-lock.json}"
    paths:
      - node_modules/

prepare:
  stage: prepare
  script:
    - npm ci   # clean install from lockfile
  cache:
    key: "${CI_COMMIT_REF_SLUG}-prepare-${hash:package-lock.json}"
    paths:
      - node_modules/
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

build:
  stage: build
  needs: ["prepare"]
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

test:
  stage: test
  needs: ["build"]
  script:
    - npm test
  artifacts:
    reports:
      junit: junit.xml
    expire_in: 1 hour
Enter fullscreen mode Exit fullscreen mode

Why it works: We separate concerns: prepare installs and caches node_modules; build and test merely needs: the previous stage, guaranteeing a clean, cached environment. Artifacts pass downstream, and we publish JUnit reports for merge‑request visibility.

5. Jenkins – The Struggle (Declarative Pipeline)

pipeline {
    agent any
    stages {
        stage('Install') {
            steps {
                sh 'npm install'
            }
        }
        stage('Build') {
            steps {
                sh 'npm run build'
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Problems: Every stage runs npm install again; no stash/cache; if the Test stage fails, you still have the built artifacts lying around, wasting disk.

6. Jenkins – The Victory (With Stashing & Caching)

pipeline {
    agent any
    options {
        timestamps()
        timeout(time: 20, unit: 'MINUTES')
    }
    stages {
        stage('Prepare') {
            steps {
                checkout scm
                // Cache node_modules using a simple timestamp-based archive
                sh '''
                if [ -d node_modules ]; then
                    tar -czf node_modules.tar.gz node_modules
                fi
                '''
                stash includes: 'node_modules.tar.gz', name: 'node-modules'
                // Install fresh from lockfile
                sh 'npm ci'
            }
        }
        stage('Build') {
            steps {
                unstash 'node-modules'
                sh 'tar -xzf node_modules.tar.gz'
                sh 'npm run build'
                stash includes: 'dist/**', name: 'build-artifacts'
            }
        }
        stage('Test') {
            steps {
                unstash 'node-modules'
                sh 'tar -xzf node_modules.tar.gz'
                sh 'npm test'
                junit '**/junit.xml'
                stash includes: 'coverage/**', name: 'coverage-report'
            }
        }
        stage('Publish') {
            steps {
                unstash 'build-artifacts'
                // Example: push to S3 or Nexus
                echo 'Publishing build artifacts...'
            }
        }
    }
    post {
        always {
            cleanWs()
        }
        success {
            echo 'Pipeline succeeded! 🎉'
        }
        failure {
            echo 'Pipeline failed – check logs.'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why it works: We stash the node_modules directory after a clean npm ci, reuse it across stages, and clean the workspace at the end. The pipeline is now deterministic, fast, and gives you clear test reports.

Why This New Power Matters

With these patterns in place, you’ll notice a few immediate wins:

  • Feedback loops shrink from hours to minutes. You know within 5 minutes if a commit breaks something.
  • Flaky tests become easier to spot because you’re not re‑installing dependencies each time—you’re testing the same bits.
  • Onboarding new developers is painless. They clone the repo, push a branch, and see the pipeline pass (or fail) without fiddling with local tooling.
  • Cost drops. Shorter runs mean fewer minutes on paid CI runners, and caching reduces bandwidth usage.

In short, you stop fighting the pipeline and start letting it work for you.

Your Turn

Pick one of the examples above, tweak it to match your stack (maybe you use Yarn, PNPM, or a Dockerized build), and push a branch. Watch the pipeline run, then celebrate when the green check appears—just like Neo finally seeing the code.

What’s your biggest CI/CD pain point right now? Drop a comment or tweet me; let’s troubleshoot together and turn that dragon into a docile pet. Happy building! 🚀

Top comments (0)