Gating Deploys Behind Tests with Travis CI Build Stages
Travis CI build stages turn deployment into a consequence of green tests: stages run sequentially, jobs within a stage run in parallel, and a failed stage skips everything after it.
02 Jun 2026, 22:08 UTC

You have a Travis CI pipeline that runs your tests, and a separate deploy step someone wired up later. The problem: they both trigger on every push, and a red test suite doesn't stop the deploy. Build stages are Travis's built-in answer to this — they let you declare that deployment is a consequence of green tests, not a coincidence of timing.
The takeaway up front: stages in .travis.yml run sequentially, jobs inside a stage run in parallel, and a stage only starts if every job in the previous stage succeeded. That single rule gives you a deployment gate with no extra tooling.
What a stage actually guarantees
Each Travis job runs in a fresh virtual machine or container. Nothing carries over between jobs unless you explicitly cache it or pass artifacts yourself. That isolation is why stages matter: they're the only ordering primitive. Without them, all your jobs start at once, and your deploy job happily ships code whose tests are still running — or already failing.
With stages, the semantics are simple and worth memorizing:
- Jobs within one stage run in parallel.
- The next stage starts only when all jobs in the current stage pass.
- If any job fails, later stages are skipped entirely.
That third point is the whole argument. A failed test in the test stage means the deploy stage never runs. You get fail-fast behavior for free.
A worked example: test, build, deploy
Here's an illustrative .travis.yml for a small Python service. The test stage fans out across two Python versions, the build stage packages the app, and the deploy stage runs only on the main branch:
language: python
cache: pip
jobs:
include:
- stage: test
python: "3.11"
script: pytest tests/
- stage: test
python: "3.12"
script: pytest tests/
- stage: build
python: "3.12"
script: python -m build
- stage: deploy
python: "3.12"
if: branch = main AND type = push
script: ./scripts/deploy.sh
stages:
- test
- build
- deploy
A few things to notice. The stages: list declares the order. The two test jobs run in parallel, and the build job waits for both. The deploy job's if: condition restricts it to pushes to main — so pull requests and feature branches run tests but never deploy.
Verify the syntax against the current Travis CI documentation before publishing your own config; key names and condition syntax have evolved, and examples written for the retired travis-ci.org may not behave identically on travis-ci.com.
Secrets and the fork problem
Deployment credentials belong in encrypted environment variables, set in the repository settings or with travis encrypt. Travis deliberately does not expose these to pull-request builds from forks — otherwise anyone could open a PR that echoes your API keys.
This shapes your stage design. A deploy job conditioned on type = push (as above) sidesteps the issue: PR builds skip it anyway. But if you ever expect secrets inside a job that also runs for pull requests, that job will fail mysteriously on forks. Keep deploy logic in push-only stages and this never bites you.
Caching: a speed lever, not a correctness lever
The cache: pip line (or cache: bundler, or custom directories) saves dependency-install time between builds. Two caveats worth internalizing:
- Caches are per-branch. A new branch starts cold.
- Caches can go stale. If a build fails in a way that smells like a bad dependency, clearing the cache in the Travis UI is a legitimate diagnostic step, not a superstition.
Never let caching paper over a missing dependency declaration. Since every job starts in a clean environment, your install step must be complete on its own; the cache only makes it faster.
The trade-off: wall-clock time
Stages buy you safety at the cost of speed. A sequential test → build → deploy pipeline takes longer than running everything concurrently, and each stage re-pays environment setup because jobs don't share filesystems. For a small project this is minutes; for a large one it can be the difference between deploying on every merge and batching deploys.
Mitigations: keep the test stage parallelized across the versions that matter (don't test six runtimes if you support two), use caching aggressively, and resist the urge to merge stages "for speed" — that reintroduces the original problem of ungated deploys.
Checking that it works
Don't trust the YAML; test the behavior. Push the config to a scratch repository and confirm three things in the Travis web UI: the two test jobs run concurrently, the build stage waits for both, and the deploy stage is skipped on a feature branch. Then deliberately break a test and push — the build and deploy stages should never start. That red-build experiment is the entire point of the design, and it takes ten minutes to prove.
Once it holds, you have a pipeline where shipping is structurally impossible without green tests — which is a much stronger guarantee than a team agreement to not deploy when the build is red.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.