07 · CI/CD Pipeline Basics¶
CI (continuous integration) means every code change is automatically built and tested. CD (continuous delivery/deployment) extends that to automatically packaging and shipping the result. This module builds a real pipeline with GitHub Actions that ties together everything from modules 1-6: build once, test it, deploy the same artifact through staging then production.
Anatomy of a pipeline¶
push to git
│
▼
CI: install deps → lint → run tests → build artifact
│ (only if all green)
▼
CD: deploy artifact to staging → smoke test
│ (only if smoke test passes, and/or manual approval)
▼
CD: deploy same artifact to production → smoke test
Each stage only runs if the previous stage succeeded — that ordering is the entire point. A pipeline that deploys regardless of test results isn't CI/CD, it's just automation of the risky part.
A GitHub Actions workflow, end to end¶
# .github/workflows/deploy.yml
name: build-test-deploy
on:
push:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
- name: Package artifact
run: tar czf release.tar.gz dist/ package.json package-lock.json
- uses: actions/upload-artifact@v4
with:
name: release
path: release.tar.gz
deploy-staging:
needs: build-and-test
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: release
- name: Deploy to staging
env:
SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
echo "$SSH_KEY" > deploy_key && chmod 600 deploy_key
scp -i deploy_key -o StrictHostKeyChecking=no release.tar.gz deploy@staging.internal:/opt/releases/
ssh -i deploy_key -o StrictHostKeyChecking=no deploy@staging.internal "/opt/myapp/bin/install-release.sh release.tar.gz"
- name: Smoke test staging
run: |
sleep 5
curl -sf https://staging.example.com/health
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # GitHub Environments can require manual approval here
steps:
- uses: actions/download-artifact@v4
with:
name: release
- name: Deploy to production
env:
SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
run: |
echo "$SSH_KEY" > deploy_key && chmod 600 deploy_key
scp -i deploy_key -o StrictHostKeyChecking=no release.tar.gz deploy@prod1.internal:/opt/releases/
ssh -i deploy_key -o StrictHostKeyChecking=no deploy@prod1.internal "/opt/myapp/bin/install-release.sh release.tar.gz"
- name: Smoke test production
run: curl -sf https://example.com/health
Key design points:
needs: build-and-test/needs: deploy-staging— each job is gated on the previous one succeeding. If tests fail,deploy-stagingnever runs.- The same artifact (
upload-artifact/download-artifact) flows through both deploy jobs — it's built exactly once, matching module 4's "build once, promote" principle. environment: productionin GitHub Actions can be configured (in repo settings → Environments) to require a human reviewer to click "approve" before that job runs — a manual gate on the highest-stakes step, same idea as theread -pconfirmation from module 4's script, but enforced by the platform instead of a shell prompt.- Secrets (
STAGING_SSH_KEY,PROD_SSH_KEY) live in GitHub's encrypted secrets store, scoped per-environment — the production key is never exposed to a staging-only job.
Fail fast: lint and unit tests before anything expensive¶
Order steps cheapest-and-fastest-to-fail first. A lint error should fail in
seconds, not after a 10-minute build. This also holds true within a single
job — npm run lint before npm test before npm run build.
Rollback: keep the last N artifacts around¶
# on the deploy target, install-release.sh keeps the previous release for fast rollback
#!/usr/bin/env bash
set -euo pipefail
RELEASE="$1"
mkdir -p /opt/myapp/releases
tar xzf "/opt/releases/${RELEASE}" -C "/opt/myapp/releases/${RELEASE%.tar.gz}"
ln -sfn "/opt/myapp/releases/${RELEASE%.tar.gz}" /opt/myapp/current
sudo systemctl restart myapp
# keep only the 5 most recent releases on disk
cd /opt/myapp/releases && ls -t | tail -n +6 | xargs -r rm -rf
# rollback = just repoint the symlink to a previous release directory and restart
ln -sfn /opt/myapp/releases/release-abc1234 /opt/myapp/current
sudo systemctl restart myapp
This symlink-swap pattern is what makes rollback fast (seconds, not a full rebuild-and-redeploy) — it's the same idea CI/CD pipelines rely on when a production smoke test fails right after deploy.
How It Actually Works¶
A CI runner is just an ephemeral VM/container executing a shell script.
Behind the YAML abstraction, a CI job is a fresh container (or VM) spun up
from a base image, your repository checked out into it via a normal git
clone, and each pipeline "step" is literally a shell command executed in
sequence, with the job failing the moment any step returns a non-zero exit
code — the same exit-status propagation as any shell script. The "magic" is
entirely in orchestration around that: scheduling runners, injecting secrets
as environment variables scoped to that one ephemeral container, caching
dependency directories between runs, and tearing the container down
afterward so no state leaks between builds.
Why pipeline caching keys matter for correctness, not just speed. A
dependency cache (node_modules, ~/.m2) is keyed by a hash of the
lockfile/manifest — when that hash changes, the cache key changes and CI
correctly rebuilds from scratch; if a pipeline used a static cache key
instead, changed dependencies would go undetected and stale packages would
silently persist across builds, which is a subtle source of "passes in CI,
fails after deploy" bugs.
Artifact promotion vs rebuilding per environment. A pipeline that builds once and promotes the same compiled artifact (Docker image digest, binary) through dev → staging → prod guarantees bit-for-bit identical code runs everywhere; a pipeline that reruns the build step per environment risks a different dependency resolution, compiler version, or flaky build step producing genuinely different bytes at each stage — reintroducing the exact "different build in each environment" problem environment-based config is supposed to eliminate.
Exercise¶
- Set up a GitHub Actions workflow for a small repo that runs lint + tests on every push, and only proceeds to a "deploy" job (even a fake one that just echoes) if both pass.
- Add a second, dependent job (
needs:) that only runs after the first succeeds, simulating the staging→production promotion. - Break a test on purpose, push, and confirm in the Actions UI that the deploy job is skipped rather than run.