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 repo’s README and think, “If only deploying felt less like defusing a bomb and more like pressing a shiny red button.” My team was shipping code manually, crossing fingers that the staging server wouldn’t implode, and every release felt like a mini‑crisis. I kept hearing the same excuse: “Our CI/CD pipeline is… there.” Yeah, it existed, but it was as reliable as a dial‑up connection during a thunderstorm.

One Friday night, after yet another 2 a.m. rollback because a stray npm install pulled in a breaking version, I muttered to myself, “What if I could just see the pipeline working, like Neo seeing the code of the Matrix?” That was the spark. I decided to treat CI/CD not as a checkbox but as a quest: slay the flaky builds, conquer the manual steps, and emerge with a pipeline that actually does what it promises.

The Revelation (The Insight)

The big “aha!” moment came when I stopped treating CI/CD as a monolithic script and started viewing it as a series of small, idempotent spells that each do one thing well. Think of each job as a potion: you brew it, test it, and if it’s spoiled, you toss it out before it ruins the whole batch.

When I broke down the pipeline into:

  1. Lint & static analysis – catch typos early.
  2. Unit tests – verify the logic without heavy dependencies.
  3. Build & package – produce an immutable artifact (Docker image, JAR, etc.).
  4. Integration tests – run against a real‑ish environment.
  5. Deploy to a preview/staging slot – smoke‑test the actual release.
  6. Promote to production – only if every prior step succeeded.

…the whole thing became predictable. The moment I saw a green checkmark on every stage, I felt like I’d finally taken the red pill and could see the underlying structure of our delivery process.

Wielding the Power (Code & Examples)

Below are three flavours of the same “working” pipeline: GitHub Actions, GitLab CI, and a classic Jenkinsfile. I’ll show the struggle (a naïve, brittle version) and then the victory (the refined, modular version).

1. GitHub Actions – The Struggle

# .github/workflows/ci.yml (struggle)
name: CI

on: [push, pull_request]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install deps
        run: npm ci
      - name: Lint
        run: npm run lint   # if this fails, the job stops – fine
      - name: Test
        run: npm test       # runs unit + integration together
      - name: Build Docker
        run: |
          docker build -t myapp:${{ github.sha }} .
          docker push myapp/${{ github.sha }}
      - name: Deploy to staging
        uses: azure/webapps-deploy@v2
        with:
          app-name: myapp-staging
          slot-name: staging
          images: myapp/${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Lint, unit, and integration tests are all lumped into npm test. If a unit test fails, you never know whether the Docker build would have succeeded.
  • The Docker image is built after tests, meaning you waste time building an image that might be garbage.
  • Deploy step runs even if the build step failed (because we didn’t use needs or if: correctly).

2. GitHub Actions – The Victory

# .github/workflows/ci.yml (victory)
name: CI

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:unit   # only unit tests here

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run build
      - name: Docker build & push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: myapp/${{ github.sha }}

  staging-deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: azure/webapps-deploy@v2
        with:
          app-name: myapp-staging
          slot-name: staging
          images: myapp/${{ github.sha }}

  prod-deploy:
    needs: [staging-deploy]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: azure/webapps-deploy@v2
        with:
          app-name: myapp-prod
          slot-name: production
          images: myapp/${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • Each job has a clear responsibility and declares its dependencies via needs.
  • If lint fails, the pipeline stops early—no wasted builds.
  • Unit tests run fast; integration/live tests can be added later in a separate job if needed.
  • Docker is built only after tests pass, guaranteeing a clean artifact.
  • Production deploy only triggers on main and only after staging succeeded.

3. GitLab CI – The Struggle

# .gitlab-ci.yml (struggle)
stages:
  - test
  - build
  - deploy

unit_test:
  stage: test
  script:
    - npm ci
    - npm test   # again, bundles everything

docker_build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

deploy_staging:
  stage: deploy
  script:
    - kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n staging
Enter fullscreen mode Exit fullscreen mode

Problems: Same as before—test stage bundles unit + integration, and there’s no gating to stop a bad build from proceeding.

4. GitLab CI – The Victory

# .gitlab-ci.yml (victory)
stages:
  - lint
  - unit-test
  - build
  - staging-deploy
  - prod-deploy

lint:
  stage: lint
  script:
    - npm ci
    - npm run lint

unit-test:
  stage: unit-test
  needs: ["lint"]
  script:
    - npm ci
    - npm run test:unit

build:
  stage: build
  needs: ["unit-test"]
  script:
    - npm ci
    - npm run build
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

staging_deploy:
  stage: staging-deploy
  needs: ["build"]
  script:
    - kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n staging

prod_deploy:
  stage: prod-deploy
  needs: ["staging_deploy"]
  only:
    - main
  script:
    - kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n production
Enter fullscreen mode Exit fullscreen mode

5. Jenkinsfile – The Struggle

// Jenkinsfile (struggle)
pipeline {
    agent any
    stages {
        stage('Test') {
            steps {
                sh 'npm ci'
                sh 'npm test'   // everything together
            }
        }
        stage('Build') {
            steps {
                sh 'docker build -t myapp:${GIT_COMMIT} .'
                sh 'docker push myapp:${GIT_COMMIT}'
            }
        }
        stage('Deploy') {
            steps {
                sh 'kubectl set image deployment/myapp myapp=myapp:${GIT_COMMIT} -n staging'
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

6. Jenkinsfile – The Victory

// Jenkinsfile (victory)
pipeline {
    agent any
    options { timeout(time: 30, unit: 'MINUTES') }
    stages {
        stage('Lint') {
            steps {
                sh 'npm ci'
                sh 'npm run lint'
            }
        }
        stage('Unit Test') {
            when { expression { return currentBuild.rawBuild.getAction(hudson.model.ParametersAction) != null } }
            steps {
                sh 'npm ci'
                sh 'npm run test:unit'
            }
        }
        stage('Build') {
            steps {
                sh 'npm ci'
                sh 'npm run build'
                sh '''
                    docker build -t myapp:${env.GIT_COMMIT} .
                    docker push myapp:${env.GIT_COMMIT}
                '''
            }
        }
        stage('Staging Deploy') {
            steps {
                sh 'kubectl set image deployment/myapp myapp=myapp:${env.GIT_COMMIT} -n staging'
            }
        }
        stage('Production Deploy') {
            when { branch 'main' }
            steps {
                input message: 'Promote to production?', ok: 'Ship it!'
                sh 'kubectl set image deployment/myapp myapp=myapp:${env.GIT_COMMIT} -n production'
            }
        }
    }
    post {
        always {
            cleanWs()
        }
        success {
            echo 'Pipeline finished successfully! 🎉'
        }
        failure {
            mail to: 'team@example.com',
                 subject: "Failed Pipeline: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
                 body: "Check the console output: ${env.BUILD_URL}"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key takeaways from the code:

  • Separate jobs/stages for each concern.
  • Use needs (GitHub/GitLab) or explicit stage ordering (Jenkins) to enforce dependencies.
  • Only promote to production after a manual gate or after a successful staging run.
  • Clean up workspace and notify on failure—small things that keep the pipeline honest.

Why This New Power Matters

Now that the pipeline is a set of isolated, testable steps, you gain:

  • Fast feedback – lint and unit tests finish in seconds, letting developers fix issues before they even wait for a build.
  • Resource efficiency – you never build a Docker image that’s destined to fail tests.
  • Confidence in releases – each stage gates the next, so a production deploy only happens when every prior check has passed.
  • Easy to extend – want to add security scanning? Drop a new job after build. Need a performance test? Add a stage before staging-deploy. The pipeline grows like a LEGO set, not a tangled mess of scripts.

Honestly, the first time I saw a green checkmark across all stages after a late‑night push, I felt like I’d just dodged a bullet in a John Wick hallway scene—except the bullet was a broken release, and the hallway was my CI pipeline. The relief was palpable, and the team’s velocity noticeably jumped.

Your Turn

Grab your favorite CI tool, split that monolithic script into tiny, purpose‑driven jobs, and watch the magic happen. Start with lint → unit test → build → deploy to a staging slot → manual promotion to prod. Add a notification step, and you’ll already be ahead of most teams out there.

What’s the first stage you’ll split out in your own pipeline? Drop a comment below—I’d love to hear about your quest! 🚀

Top comments (0)