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:

  1. Checkout the repo.
  2. Set up the toolchain (Node version, Python version, build cache).
  3. Install dependencies (with lockfile, for reproducibility).
  4. Lint — catch style / obvious bugs before tests run.
  5. Build — compile, transpile, bundle. Fails fast if broken.
  6. Test — unit tests, integration tests.
  7. Security scan — dependencies (npm audit, pip-audit, Trivy), SAST (CodeQL, Semgrep), secrets (gitleaks).
  8. Package — produce an artifact (container image, .deb, jar, zip).
  9. 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 DeliveryContinuous Deployment
Post-CI actionArtifact built + sitting in registryArtifact built + automatically deployed
Gate to prodHuman clickNone (tests ARE the gate)
Good whenRegulated industries, high-risk changes, audit needHigh test coverage, feature-flag discipline, fast rollback
Bad whenBecomes “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).

EngineConfig fileRunner model
GitHub Actions.github/workflows/*.ymlGitHub-hosted or self-hosted; workflow-centric
GitLab CI.gitlab-ci.ymlShared or project runners; stages + jobs
JenkinsJenkinsfile (Groovy DSL)Master + agents; plugin ecosystem, classic
Azure DevOps Pipelinesazure-pipelines.ymlMicrosoft-hosted or self-hosted agents
CircleCI.circleci/config.ymlOrbs ecosystem; fast caching
Drone / Woodpecker.drone.ymlContainer-native, simple
Tekton / Argo WorkflowsKubernetes CRDsPure K8s, used for GitOps-native CI
Buildkitepipeline.ymlAgents 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 --cov

Minimal 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 imageghcr.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:

LayerWhat it testsSpeedUsually in CI?
Static analysis / lintStyle, types, obvious bugsmsYes, first
Unit testsOne function / module in isolationsYes
Integration testsMultiple modules, real DB, fake network10s of sYes
Contract testsService-to-service API contractssYes, if microservices
End-to-end testsReal browser, real stackminSelectively (smoke only on PR)
Load / performanceSLO regressionsminNightly / pre-release
Security scansSAST, deps, secretss-minYes

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, not ubuntu-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.

See also