DEV Community

Timevolt
Timevolt

Posted on

CI/CD Pipelines that Actually Work: The Quest for the Holy Grail

The Quest Begins (The “Why”)

Honestly, I was tired of watching my teammates stare at a red “failed” badge on every pull request while I sipped my third coffee of the morning. We had a shiny repo, great tests, and a product that actually shipped… but our CI/CD felt like a tangled set of extension cords—plug it in somewhere and hope it doesn’t spark.

GitHub Actions felt magical at first, until a workflow ran for 20 minutes because we’d accidentally cached the whole node_modules folder and reinstalled dependencies on every job. GitLab CI? We tried to mirror our GitHub setup, only to discover that rules: behaves nothing like if: and our merge trains started to look like a game of whack‑a‑moon. Jenkins… well, let’s just say the UI looked like it was designed in 2008 and the Groovy scripts felt like deciphering ancient runes.

I kept asking myself: Is there a way to make pipelines that are fast, reliable, and actually enjoyable to maintain? That question turned into a weekend‑long adventure, and what I found felt like finally locating the Holy Grail of automation.

The Revelation (The Insight)

The breakthrough wasn’t a new tool—it was a shift in mindset. Instead of treating each CI system as a black box where you throw YAML or Groovy and hope for the best, I started thinking of pipelines as composable stages with clear contracts:

  1. Fast feedback – lint, unit tests, and static analysis run on every push, finishing under 2 minutes.
  2. Gatekeeping – longer integration, build, and security scans run only on branches that pass the fast feedback.
  3. Deploy‑only‑when‑ready – production deployments happen automatically from the main branch after a manual approval (or a scheduled window) and only if the previous stages succeeded.

Once I codified those contracts, the same logic could be expressed in GitHub Actions, GitLab CI, or Jenkins with only syntax differences. The real win? Reusability. I extracted common steps into reusable workflows/templates, so adding a new service meant copying a few lines instead of rewriting an entire pipeline.

Wielding the Power (Code & Examples)

Before: The “spaghetti” GitHub Actions workflow

name: CI

on:
  push:
    branches: [ main, feature/* ]
  pull_request:
    branches: [ main ]

jobs:
  build-test-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # 😱 Mistake #1: caching the whole node_modules every time
      - 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 dependencies
        run: npm ci

      # 😱 Mistake #2: running lint, tests, build, and deploy all in one job
      - name: Lint
        run: npm run lint

      - name: Test
        run: npm test

      - name: Build
        run: npm run build

      - name: Deploy to staging
        if: github.ref == 'refs/heads/main'
        uses: azure/webapps-deploy@v2
        with:
          app-name: my-staging-app
          slot-name: staging
          publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
Enter fullscreen mode Exit fullscreen mode

The problems?

  • Cache bloat – we cached the download folder, not the actual node_modules, causing restores to pull a fresh copy each run.
  • Monolithic job – if lint failed, we still wasted time installing dependencies and building. No fast feedback.
  • Hard‑coded deploy – the deploy step lived inside the same job, making it impossible to gate with manual approvals.

After: Composable, fast‑feedback workflow

name: CI

on:
  push:
    branches: [ main, feature/* ]
  pull_request:
    branches: [ main ]

# ---- Reusable workflow for lint + tests (fast feedback) ----
jobs:
  lint-test:
    uses: ./.github/workflows/lint-test.yml   # <-- calls another file
    with:
      node-version: '20'

  # ---- Build only if lint/test passed ----
  build:
    needs: lint-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - name: Upload build artifact
        uses: actions/upload-artifact@v3
        with:
          name: build-output
          path: dist/

  # ---- Deploy to staging (manual approval) ----
  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3
      - name: Download build artifact
        uses: actions/download-artifact@v3
        with:
          name: build-output
          path: ./dist
      - name: Deploy to Azure Staging
        uses: azure/webapps-deploy@v2
        with:
          app-name: my-staging-app
          slot-name: staging
          publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
Enter fullscreen mode Exit fullscreen mode

lint-test.yml (the reusable piece)

name: Lint & Test

on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string

jobs:
  lint-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

What changed?

  • Fast feedback: lint & test run in ~45 seconds; if they fail, the workflow stops immediately.
  • Correct caching: actions/setup-node handles npm caching automatically—no more manual path fiddling.
  • Clear separation: build, test, and deploy are distinct jobs, each with its own needs: dependency.
  • Manual gate: you can add an environment: with protection rules or a workflow_dispatch: trigger for prod deployments.

GitLab CI – Same ideas, different syntax

stages:
  - lint-test
  - build
  - deploy

lint-test:
  stage: lint-test
  image: node:20
  cache:
    paths:
      - .npm
  script:
    - npm ci
    - npm run lint
    - npm test

build:
  stage: build
  needs: ["lint-test"]
  image: node:20
  cache:
    - .npm
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

deploy_staging:
  stage: deploy
  needs: ["build"]
  only:
    - main
  when: manual   # <- requires a click in GitLab UI
  image: node:20
  script:
    - npm ci   # just to ensure deps are present; artifacts already have dist/
    - az webapp deployment source config-zip --resource-group $RG --name $APP --src dist/
Enter fullscreen mode Exit fullscreen mode

Jenkins – Declarative Pipeline with Shared Library

Assuming you have a shared library ci-utils that contains a lintTest() step:

pipeline {
    agent any
    options { timestamps() }

    stages {
        stage('Lint & Test') {
            steps {
                script {
                    ciUtils.lintTest(nodeVersion: '20')
                }
            }
        }

        stage('Build') {
            when { expression { return currentBuild.previousBuild?.result == 'SUCCESS' } }
            agent { label 'node' }
            steps {
                checkout scm
                sh 'npm ci'
                sh 'npm run build'
                archiveArtifacts artifacts: 'dist/**', fingerprint: true
            }
        }

        stage('Deploy to Staging') {
            when { branch 'main' }
            steps {
                input message: 'Deploy to Staging?', ok: 'Deploy'
                sh '''
                npm ci
                az webapp deployment source config-zip --resource-group $RG --name $APP --src dist/
                '''
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Traps to avoid (the “monsters” on our quest):

  1. Over‑caching – cache only what truly changes (e.g., ~/.npm or ~/Library/Caches), not the entire repo.
  2. Mixing concerns – keep lint/test, build, and deploy in separate stages/jobs so failures are isolated and fast.
  3. Hard‑coding secrets – always pull them from the platform’s secret store (secrets, CI_JOB_TOKEN, Credentials binding).

Why This New Power Matters

Now every push gets a green check in under a minute, and we know instantly if we broke something. When the fast feedback passes, we can spend our time on longer‑running jobs (integration tests, security scans, performance benchmarks) without fearing they’ll block the whole pipeline. Deployments are deliberate—one click or a scheduled window—yet fully automated once the gate is opened.

The best part? Portability. The same logic expressed in GitHub Actions, GitLab CI, or Jenkins means you’re not locked into a single platform. If your company decides to migrate, you just copy the reusable workflow or shared library and adjust the syntax—no re‑architecting required.

Take a moment: look at your current CI config. Does it feel like a tangled mess of “run everything, hope for the best”? If so, try extracting a single reusable step—maybe a lint‑test job—and watch how fast the feedback loop becomes.

Your challenge: Pick one repo, create a reusable lint/test workflow (or shared library), and make your build job depend on it. Then add a manual approval step for staging deployment. When you see that first green check after a push, you’ll feel like you just snatched the Holy Grail from the clutches of a guarded castle—except the dragon was a flaky pipeline, and the sword was a few lines of YAML.

Go forth, automate wisely, and may your builds always be green! 🚀

Top comments (0)