Anatomy of a CI Pipeline
Anatomy of a CI Pipeline
Every time a developer pushes code, a CI pipeline executes a precisely ordered sequence of checks. That sequence is not arbitrary — it is engineered for one purpose: surface failures as early and as cheaply as possible. Understanding the anatomy of a pipeline means understanding why each stage exists, what it catches, what it costs if you skip it, and how to order stages to minimize wasted compute.
The Four Canonical Stages
Regardless of the tool — GitHub Actions, GitLab CI, Jenkins, CircleCI, Buildkite — a well-designed CI pipeline passes through four logical stages in sequence: Lint → Build → Test → Package. Each stage has a distinct failure mode and a different cost profile.
Stage 1 — Lint
Linting runs before compilation. Its job is to reject code that is malformed, violates style rules, or contains known anti-patterns — without executing a single line of application logic. Because it operates on source text alone, lint is the fastest possible signal a pipeline can produce.
At big-tech scale, lint jobs are parallelized across changed files and typically complete in under 30 seconds. Common tools: ESLint for JavaScript/TypeScript, pylint/ruff for Python, golangci-lint for Go, shellcheck for shell scripts, and hadolint for Dockerfiles.
--max-warnings=0 flag on ESLint is a production-grade choice: warnings become errors. Many teams start with warnings allowed and gradually tighten the gate. Google, Meta, and Amazon enforce zero lint warnings in CI — "fix it now" costs minutes; "fix it later" costs weeks of accumulated debt.Stage 2 — Build
The build stage compiles or transpiles source code into an executable form. It proves that every import resolves, every type is satisfied, and every asset can be bundled. A failed build means the artifact does not exist — there is nothing to test or ship.
Build reproducibility is the key concern. The same commit must produce the same binary on any runner, on any day. This requires locking dependency versions (package-lock.json, go.sum, Cargo.lock), pinning tool versions, and — for compiled languages — recording compiler version in CI output.
Stage 3 — Test
The test stage is typically the longest and most expensive part of the pipeline. It runs the automated test suite against the build artifact. A well-structured test stage splits tests by speed and scope: unit tests run first (milliseconds each, no I/O), integration tests run next (real DB/cache connections, slower), and end-to-end tests run last (full browser automation, minutes).
At scale, large test suites are sharded: split across N parallel runners so total wall-clock time stays bounded. GitHub Actions uses strategy.matrix for this; Buildkite has native parallelism primitives. Coverage is measured and a minimum threshold (typically 80 %) is enforced as a quality gate — a PR that drops coverage below the threshold is rejected.
Stage 4 — Package
Packaging runs only after all tests pass. It creates the deployable artifact: a Docker image, a JAR, a compiled binary, a Helm chart tarball. This is the output the CD system will promote through environments. Because packaging is expensive (image layers, cache warming, registry push), it is gated behind a passing test stage — you never want to push an untested image.
Best practice: tag images with the full git SHA, not latest. This makes every artifact traceable back to the exact commit that produced it, which is essential for rollbacks and incident investigation.
Fail-Fast Ordering — the Economics of Stage Order
The ordering Lint → Build → Test → Package is not a convention — it is an economic decision. Each stage is more expensive than the one before it. If a lint error would have caught a problem in 10 seconds, running a 15-minute test suite before the lint check wastes 14 minutes and 50 seconds of compute, multiplied across every developer and every push in the organization.
This principle is enforced with the needs keyword in GitHub Actions (or dependencies/needs in GitLab CI). A stage that declares needs: [lint] will not start until lint succeeds, and will be skipped entirely if lint fails. Fail-fast is the default behavior when stages are properly chained.
Putting It Together — the Complete Dependency Graph
Real pipelines at companies like Shopify, Stripe, and Airbnb follow this same graph structure. The parallelism in the test layer is where most teams recover significant wall-clock time — but the serial gating at lint and build ensures no compute is wasted on obviously broken code.