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:
- Lint & static analysis – catch typos early.
- Unit tests – verify the logic without heavy dependencies.
- Build & package – produce an immutable artifact (Docker image, JAR, etc.).
- Integration tests – run against a real‑ish environment.
- Deploy to a preview/staging slot – smoke‑test the actual release.
- 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 }}
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
needsorif: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 }}
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
mainand 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
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
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'
}
}
}
}
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}"
}
}
}
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 beforestaging-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)