CI/CD Fundamentals
CI (Continuous Integration) is “every change is merged, built, and tested automatically, continuously, all day long.” CD is overloaded: it means either Continuous Delivery (every commit that passes CI produces a release-ready artifact; humans decide when to deploy) or Continuous Deployment (the commit itself goes straight to production). Both are downstream of the same well-built pipeline.
The mental model — one conveyor belt
dev writes code
│
▼
git push ──── webhook ────► CI runner
│
┌──────────┼──────────┐
▼ ▼ ▼
lint build tests
│ │ │
└──────────┴──────────┘
│
all green?
│
▼
artifact
(image, binary, zip)
│
▼
push to registry
│
▼
deploy to env (CD)
staging → canary → prod
Every stage either passes and proceeds or fails and stops. Failure is fast, loud, visible.
CI — continuous integration
What “integration” means
Historically, devs worked on branches for weeks then “integrated” at the end — painful merges, tests nobody ran in isolation. CI inverts this: merge to main at least daily, run the full test suite on every change. Problems caught the same day they’re introduced.
Trunk-based development is the extreme form: short-lived branches (hours, not weeks), merging straight into main.
What a CI pipeline actually does
The minimum viable pipeline:
- Checkout the repo.
- Set up the toolchain (Node version, Python version, build cache).
- Install dependencies (with lockfile, for reproducibility).
- Lint — catch style / obvious bugs before tests run.
- Build — compile, transpile, bundle. Fails fast if broken.
- Test — unit tests, integration tests.
- Security scan — dependencies (
npm audit,pip-audit, Trivy), SAST (CodeQL, Semgrep), secrets (gitleaks). - Package — produce an artifact (container image, .deb, jar, zip).
- Publish — push artifact to a registry.
Anything past publish is CD.
Quality gates
The gate is the thing that says “this change is safe to merge.” Typical gates:
- All CI jobs green
- ≥ N approving reviews
- No merge conflicts
- Branch up to date with main
- No secrets in diff
- Coverage didn’t drop by > X %
Enforced by branch protection rules (GitHub / GitLab) — not just by social convention.
CD — continuous delivery vs continuous deployment
Same pipeline, different discipline at the end:
| Continuous Delivery | Continuous Deployment | |
|---|---|---|
| Post-CI action | Artifact built + sitting in registry | Artifact built + automatically deployed |
| Gate to prod | Human click | None (tests ARE the gate) |
| Good when | Regulated industries, high-risk changes, audit need | High test coverage, feature-flag discipline, fast rollback |
| Bad when | Becomes “continuous waiting for a human” | Tests are weak — broken code reaches users |
Most real orgs: Continuous Delivery to staging + one-click to prod, sometimes evolving to continuous deployment for specific services as confidence grows.
Pipeline engines — who runs the jobs
All modern engines share a common shape: a YAML (or Jenkinsfile) in your repo declaratively describes jobs, which run on runners (containers or VMs).
| Engine | Config file | Runner model |
|---|---|---|
| GitHub Actions | .github/workflows/*.yml | GitHub-hosted or self-hosted; workflow-centric |
| GitLab CI | .gitlab-ci.yml | Shared or project runners; stages + jobs |
| Jenkins | Jenkinsfile (Groovy DSL) | Master + agents; plugin ecosystem, classic |
| Azure DevOps Pipelines | azure-pipelines.yml | Microsoft-hosted or self-hosted agents |
| CircleCI | .circleci/config.yml | Orbs ecosystem; fast caching |
| Drone / Woodpecker | .drone.yml | Container-native, simple |
| Tekton / Argo Workflows | Kubernetes CRDs | Pure K8s, used for GitOps-native CI |
| Buildkite | pipeline.yml | Agents on your infra, hosted orchestration |
Once you know one, picking up the others is a day.
Minimal GitHub Actions example
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -e .[dev]
- run: ruff check .
- run: pytest --covMinimal GitLab CI example
# .gitlab-ci.yml
stages: [lint, test, build, deploy]
lint:
stage: lint
image: python:3.12
script:
- pip install ruff
- ruff check .
test:
stage: test
image: python:3.12
script:
- pip install -e .[dev]
- pytest --cov
build:
stage: build
image: docker:latest
services: [docker:dind]
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
only: [main]Artifacts — the unit of flow
A CI pipeline should produce a single, immutable artifact per commit. “Build once, deploy many times.” Never rebuild to deploy to a different environment — that guarantees staging and prod diverge.
Typical artifact choices:
- Container image —
ghcr.io/org/app:sha-abc123. Most common. - OS package —
.deb,.rpm, pushed to Artifactory / Nexus. - Zip / tar — for Lambda / serverless.
- Language package — npm, PyPI, Maven.
Tag by immutable SHA, not latest. latest moves; your deployment needs to point at a specific version.
Environment promotion
build → publish → deploy to dev → deploy to staging → deploy to prod
(same image, different config)
The same artifact moves through environments; only config changes (env vars, secret references, resource sizes). Config belongs in git too — per-env overlays, not per-env images.
Caching — the thing that makes CI tolerable
Cold CI that reinstalls every dep takes 10x longer than warm CI. Caches:
- Dependency cache —
~/.npm,~/.cache/pip, Gradle cache, Go module cache. - Docker layer cache — registry cache or buildx cache.
- Test caches — many frameworks (pytest-xdist, gradle, bazel) cache test results keyed by inputs.
- Monorepo build tools (Nx, Turbo, Bazel) — skip untouched projects entirely.
Aim: a no-op re-run (push same SHA) should be < 1 minute.
Secrets in CI
Never put real secrets in the YAML. Every engine has a secrets store:
- GitHub Actions — repo/org/environment secrets;
${{ secrets.NAME }} - GitLab CI — CI/CD variables (mark masked + protected)
- Jenkins — Credentials plugin
- Cloud: OIDC federation into AWS/GCP/Azure without long-lived keys
Prefer OIDC — the CI runner exchanges a short-lived token with the cloud provider, no static key to leak. Detailed in Secrets Management.
Pipeline health — the things that kill you
The classic failure modes:
- Flaky tests. One test fails 1% of the time. Suddenly no PR is mergeable without three retries. Quarantine + fix; never accept flakiness.
- Slow tests. Pipeline > 20 min → devs stop running it locally, re-runs get expensive. Hard budget: < 10 min P95.
- Master red. Main branch is broken, nobody can deploy. Revert first, fix forward later.
- Trust erosion. Devs learn to click “rerun” instead of looking at the log. Every failure must be noisy, debuggable, and rare enough to matter.
- Plugin rot (Jenkins). Plugins haven’t been updated in years; upgrading breaks things. Jenkins needs dedicated maintenance.
Testing layers in CI
CI should run multiple layers, from cheapest/fastest to most expensive:
| Layer | What it tests | Speed | Usually in CI? |
|---|---|---|---|
| Static analysis / lint | Style, types, obvious bugs | ms | Yes, first |
| Unit tests | One function / module in isolation | s | Yes |
| Integration tests | Multiple modules, real DB, fake network | 10s of s | Yes |
| Contract tests | Service-to-service API contracts | s | Yes, if microservices |
| End-to-end tests | Real browser, real stack | min | Selectively (smoke only on PR) |
| Load / performance | SLO regressions | min | Nightly / pre-release |
| Security scans | SAST, deps, secrets | s-min | Yes |
Keep the PR-gate short (< 10 min). Run the heavy stuff on a schedule.
Build reproducibility
A CI pipeline is only as good as its reproducibility. Principles:
- Lockfiles committed (
package-lock.json,poetry.lock,go.sum) — pin transitive deps. - Pin runner images (
ubuntu-22.04, notubuntu-latest) for production pipelines. - Pin action/plugin versions (
actions/[email protected]over@main). - Containerised builds — same Dockerfile locally and in CI; “works on my machine” becomes a feature.
- No network during build if possible — pull deps in a prior stage.
Monorepo vs polyrepo — pipeline implications
- Polyrepo: each repo has its own pipeline; simple. Cross-service changes become multi-PR dances.
- Monorepo: one pipeline that intelligently skips unchanged projects (Bazel, Nx, Turbo). Powerful but needs tooling investment.
There’s no universal right answer; pick based on team size and coordination load.
Relationship to GitOps
CI produces artifacts and updates manifests in a deployment repo. A GitOps controller (ArgoCD / Flux) watches that repo and applies changes to clusters. CI never touches prod directly — CD = “commit to the gitops repo.” This is the dominant modern pattern for k8s. See GitOps Fundamentals.