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:
- Every commit gets a clean slate.
- Dependencies are restored, not guessed.
- Tests run in parallel, not sequentially.
- 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
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/
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
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
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'
}
}
}
}
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.'
}
}
}
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)