This lesson comes at things from a different angle to the previous ones, and it is worth saying so up front: you are not going to choose Travis CI for a new project. What is very likely is that you will run into it — in an internal repository nobody has touched since 2019, in an open source dependency you want to contribute to, in the migration you get handed when you join a team — and that you will have to understand what that thirty-line .travis.yml does and translate it. And there is a second reason to devote a whole lesson to it: Travis invented a good part of what this course takes for granted. The CI file versioned alongside the code, the language version matrix, free unlimited CI for open source and the green badge in the README are all its doing; GitHub Actions and GitLab CI inherit its central idea. And then what happened, happened, which is the most instructive part: a change of ownership, a change of business model, a mass migration to another tool and a security incident that left a very hard lesson about entrusting secrets to a third party. We are going to look at what it contributed, how its fixed-phase model works, the Reservalia pipeline forced to fit inside it, what happened, and above all how to migrate a .travis.yml to whatever you use today.
Contents
- What Travis contributed and why it matters
- The fixed-phase model
- A basic
.travis.ymland the version matrix - The Reservalia pipeline forced to fit
- Stages, caches and encrypted secrets
- What happened: ownership, business model and exodus
- The 2021 incident and the lesson about secrets
- Migrating a
.travis.yml: equivalence table - A complete migration, step by step
- The portability lesson
- When Travis still makes sense
- Common Mistakes and Tips
- Exercises
- Conclusion
- What Travis contributed and why it matters
Travis CI appeared in 2011, when normal CI meant a Jenkins installation somebody maintained and where configuring a project meant asking an administrator for a job and filling in a form (06-01). Travis proposed something else: a file in your repository and that is it.
# A .travis.yml from 2013. This was all of it. And it was revolutionary.
language: node_js
node_js:
- "0.10"
- "0.12"With those four lines, every push triggered a build, on two versions of Node, on a clean machine, without having asked anybody's permission or installed anything. Four specific innovations, all invisible today because they are the standard:
| Contribution | What it changed | Where it lives today |
|---|---|---|
| Configuration in the repository | The pipeline branches and is reviewed with the code | Jenkinsfile, .gitlab-ci.yml, config.yml, workflows: all of them |
| Convention over configuration | language: node_js implies installing Node, running npm ci and npm test without saying so |
The setup-* actions, buildpacks, cimg/* |
| Version matrix | Testing on several versions was a list, not N copied jobs | matrix / parallel: matrix (02-04) |
| Free and unlimited for open source | CI stopped being a corporate privilege | Today's free plans, which exist because Travis normalised them |
The cultural effect was enormous: for years, the green Travis badge in the README was the sign that an open source project was serious. Thousands of projects adopted automated testing because the cost of entry dropped to a single file. It is hard to overstate how much of today's CI culture comes from there.
That is the reason to study Travis even if you never use it: understanding the .travis.yml is understanding the mental model everything else derives from, including its limitations, which is what pushed its successors to design themselves differently.
- The fixed-phase model
Here is the structural difference from everything we have seen. Jenkins, GitLab, CircleCI and GitHub Actions let you define the stages and their dependencies. Travis does not: it has a predefined phase lifecycle and you fill in the ones you need.
flowchart TD
A["apt addons"] --> B["before_install"]
B --> C["install<br/>default according to language"]
C --> D["before_script"]
D --> E["script<br/>the one that decides success or failure"]
E --> F{"result"}
F -->|"success"| G["after_success"]
F -->|"failure"| H["after_failure"]
G --> I["before_deploy"]
I --> J["deploy"]
J --> K["after_deploy"]
G --> L["after_script"]
H --> L
| Phase | What for | Important note |
|---|---|---|
before_install |
Prepare the system (apt repositories, tooling) | A failure here is an error, not a build failure |
install |
Install dependencies | It has a default value based on language |
before_script |
Prepare the environment (start the database, migrate) | A failure here is also an error |
script |
What decides whether the build passes | A failure here is a build failure. The distinction is subtle and confusing |
after_success / after_failure |
Coverage, notifications | Their result does not affect the build |
before_deploy / deploy / after_deploy |
Publish and deploy | deploy uses predefined providers |
after_script |
Cleanup | Always runs |
What you gain and what you lose with fixed phases:
You gain brutal simplicity. There is no need to decide the pipeline's structure: the structure is already there and you fill in the gaps. For 90% of the open source projects of 2013 — install, test, report — it was exactly what was needed, and the file fitted on one screen. It also makes files comparable across projects: you know where to look.
You lose everything a real delivery pipeline needs:
- There is no graph. You cannot say "publish depends on quality and on test but not on the documentation". The order is what it is.
- There are no jobs with arbitrary dependencies.
stagesarrived late and they are sequential, not a DAG. - There is no parallelisation within a job. The sharding of 02-04 has to be simulated with matrix entries and environment variables.
- Passing artifacts between jobs does not exist natively. Each matrix job is independent and starts clean; to pass
dist/from one job to another you have to upload it to S3 or some other store yourself. This is the most severe limit: the single-artifact promotion of 02-06 cannot be expressed in the model. - Conditionality is poor.
if:exists, but there is nothing likerulesor rich expressions.
The conclusion, which is what makes this lesson didactic: the fixed-phase model is excellent for CI and structurally insufficient for CD. And that explains why the successors — GitLab with stages and later needs, CircleCI with workflows from the start, GitHub Actions with needs — put the graph at the centre. It was not fashion: it was the correction of a specific limitation.
- A basic
.travis.yml and the version matrix
.travis.yml and the version matrixlanguage: node_js
dist: jammy # 1 · Ubuntu base image
node_js: [ "20", "22" ] # 2 · implicit matrix: 2 jobs
services: [ postgresql ] # 3 · services preinstalled in the VM
cache:
npm: true # 4 · cache the provider knows about
directories: [ node_modules ]
before_install:
- psql -c 'CREATE DATABASE reservalia_test;' -U postgres
install:
- npm ci # replaces the default install
script: # 5 · what decides the result
- npm run lint
- npm test
after_success:
- bash <(curl -s https://codecov.io/bash) # 6 · its failure does not break the build
notifications:
email: false
slack:
rooms:
secure: "AbCd...==" # 7 · value encrypted with the repository's public keydistchooses the base image (trusty,xenial,bionic,focal,jammy). It is the closest thing toruns-on, and a classic source of broken builds: projects pinned todist: trustythat stopped working when that image was retired.- The implicit matrix is the idea that got copied the most: listing values for the language key generates one job per value.
servicesenables services already installed in the VM. It is different from GitLab's or GitHub'sservices, which start containers: here you do not pick the version precisely, you run whatever the image ships. Less control, less ceremony.cache: npm: trueis caching by convention: the provider knows what to cache for each language. Convenient, and with the usual trade-off — cachingnode_modulesinstead of npm's download directory produces corrupted states when the Node version changes, exactly the invalidation problem of 04-02.scriptis the deciding phase. Each command in the list runs and if one fails, the following ones run anyway; the build ends up failing. That is different from almost every modern tool, where a failed step cuts the job, and it is a source of confusion when reading logs.after_successdoes not affect the result. If the coverage upload fails, the build stays green. Sometimes that is what you want and sometimes it hides the fact that you have not uploaded coverage for a month.secure:is a value encrypted with the repository's public key, generated withtravis encrypt. Section 5.
An explicit matrix, with what made it powerful:
jobs: # it used to be called "matrix"
include:
- node_js: "22"
env: SUITE=integration
services: [ postgresql, redis ]
- node_js: "22"
os: osx # macOS, one of its historical selling points
- node_js: "22"
arch: arm64
exclude:
- node_js: "20"
os: osx
allow_failures: # jobs that may fail without breaking the build
- node_js: "nightly"
fast_finish: true # reports the result without waiting for the allow_failuresallow_failures with fast_finish is a good idea that still holds: testing against the language's development version without blocking anybody, and finding out early that something is about to break. It is the equivalent of continue-on-error with the matrix of 02-04.
- The Reservalia pipeline forced to fit
Fourth translation of the same pipeline, and the first in which we have to explain what cannot be expressed.
language: node_js
node_js: [ "22" ]
dist: jammy
os: linux
services: [ postgresql, docker ]
cache:
directories: [ $HOME/.npm ]
env:
global:
- IMAGE=reservalia/api
- ECR=123456789012.dkr.ecr.eu-west-1.amazonaws.com
stages: # 1 · sequential, no graph
- verify
- build
- name: publish
if: branch = main AND type = push # 2
jobs:
include:
# ---------------- verify ----------------
- stage: verify
name: "Quality"
install: npm ci --prefer-offline
script:
- npx prettier --check .
- npm run lint
- npm run typecheck
- stage: verify # 3 · sharding, by hand
name: "Tests 1/4"
env: SHARD=1
install: npm ci --prefer-offline
before_script:
- psql -c 'CREATE DATABASE reservalia_test;' -U postgres
- npm run migrate
script: npm test -- --shard=$SHARD/4
- stage: verify
name: "Tests 2/4"
env: SHARD=2
install: npm ci --prefer-offline
before_script:
- psql -c 'CREATE DATABASE reservalia_test;' -U postgres
- npm run migrate
script: npm test -- --shard=$SHARD/4
# … and two more almost identical entries for 3/4 and 4/4
# ---------------- build ----------------
- stage: build
name: "Build image"
install: skip
script:
- docker build -f apps/api/Dockerfile -t $IMAGE:$TRAVIS_COMMIT .
- trivy image --severity HIGH,CRITICAL --exit-code 1 $IMAGE:$TRAVIS_COMMIT
after_success:
# 4 · for the next stage to see the image, it has to be pushed NOW
- echo "$ECR_PASSWORD" | docker login -u AWS --password-stdin $ECR
- docker tag $IMAGE:$TRAVIS_COMMIT $ECR/$IMAGE:$TRAVIS_COMMIT
- docker push $ECR/$IMAGE:$TRAVIS_COMMIT
# ---------------- publish ----------------
- stage: publish
name: "Promote by digest"
install: skip
script:
# 5 · the digest has to be looked up again: it did not travel from the previous stage
- DIGEST=$(aws ecr describe-images --repository-name reservalia/api
--image-ids imageTag=$TRAVIS_COMMIT
--query 'imageDetails[0].imageDigest' --output text)
- ./scripts/promote.sh "$ECR/$IMAGE@$DIGEST"
deploy: # 6 · predefined providers
provider: s3
bucket: reservalia-web
local_dir: apps/web/dist
skip_cleanup: true
on: { branch: main }What ends up forced, which is the instructive part:
- The
stagesare sequential, not a graph.buildcannot start until the five jobs inverifyfinish, even though it only depends on the sources. It is the stage barrier of 04-01 with no way out: there is noneeds. - Conditionality is limited. The
if:language covers branch, event type, tag and environment variables, and little else. There are no path filters like those of 02-07, so the monorepo selective execution of 04-04 cannot be expressed. - Sharding means copying and pasting four entries. There is no
parallelism: 4and no matrix that generates shards in one line; each partition is ajobs.includeentry with itsinstalland itsbefore_scriptrepeated. Four almost identical blocks: exactly the duplication 04-05 taught you to eliminate and which here has no solution within the file. - There is no passing of artifacts between jobs. The
dist/and the image do not travel from thebuildstage to thepublishstage: every job starts on a clean machine. The only way out is to upload the artifact to an external store immediately and download or reference it again later. It works, but it makes the external store a mandatory part of the pipeline and stops the build and the publish from being separable steps. - The most serious consequence: because the digest cannot travel, it has to be looked up again by tag. And that breaks a guarantee the course has been defending since 02-06: promotion by digest requires the identifier being promoted to be exactly the one the build produced. Looking it up by tag introduces a window — small, but real — in which that tag could point to a different image. In Travis it can be mitigated (unique tags per commit, registries with immutable tags), not cleanly eliminated.
deploywith providers is Travis's most convenient part: dozens of providers (S3, Heroku, npm, PyPI, GitHub Releases) configured in four lines. For publishing an open source package it is still about as direct as anything out there. For deploying to ECS with a canary there is no provider and you end up writing the script anyway.
An honest verdict: the pipeline fits, but it loses the graph, loses declarative sharding, loses selective execution and compromises promotion by digest. It is not that Travis is badly made; it is that it was designed for "build and test an open source project" and this pipeline is continuous delivery.
- Stages, caches and encrypted secrets
Stages arrived in 2017, late, and they are sequential groups of parallel jobs — GitLab's original model, without the later needs. They added something important: if: at stage level, and the possibility of a deployment phase after the tests.
Caches: by convention (cache: npm|bundler|pip) or by explicit directories. With two warnings that hold for any tool. First, caching node_modules is a bad idea compared with caching the download directory: node_modules contains binaries compiled for one specific Node version and architecture, and restoring it in another context produces cryptic failures (04-02). Second, invalidating the Travis cache was done by deleting it from the interface or with the CLI, not by changing a key: there is no checksum-based key, so the cache can stay stale indefinitely. It is precisely the control CircleCI made explicit (06-03).
Encrypted secrets, the most characteristic part and the most relevant to what comes next:
# Encrypts a value with the repository's public key and adds it to the .travis.yml
travis encrypt AWS_SECRET_ACCESS_KEY="AKIA..." --add env.global
# Encrypts a whole file (a private key, a keystore)
travis encrypt-file keys/deploy.pem --addThe idea was elegant: the encrypted secret lives in the repository, so the .travis.yml is self-contained and works for anyone who forks it without exposing anything, because only Travis has the private key. Two design properties made it reasonably secure: encrypted values are not decrypted in pull request builds from forks — precisely so that a malicious PR could not read them, which is the same reasoning as pull_request_target in 04-03 — and Travis masked the values in the logs.
And in those two properties lies the story of section 7.
- What happened: ownership, business model and exodus
The facts, in order, because the pattern matters more than the dates:
January 2019: change of ownership. Travis CI was acquired by Idera. Shortly afterwards there was a significant departure of engineering staff, widely commented on in the community. For users, the immediate effect was uncertainty about the product's direction.
Late 2020: the end of the unlimited free model for open source. A credit system was introduced with a limited allocation for open projects, renewable on request. The justification was reasonable — compute costs are real and abuse through cryptocurrency mining was a genuine problem across the industry — but the change affected the value proposition that had defined Travis for ten years.
2019-2021: GitHub Actions. GitHub launched Actions in the place where most open source projects' code already lived, with generous free minutes for public repositories and zero integration configuration. The combination of both factors produced a mass migration: thousands of projects swapped their .travis.yml for .github/workflows/ci.yml within months.
What you should take from this is not a criticism of Travis or of Idera, but three observations applicable to any tool you choose today:
- Your CI provider's business model can change, and you do not decide it. A free plan, some included minutes or a pricing tier can change with short notice. If your operation depends critically on those conditions, you have an exposure worth acknowledging.
- Integration beats technical quality. Actions did not win by being a better tool than Travis in 2020 — debatable in several respects — but by being where the code already was. It is the criterion 06-07 will put first for its real weight: where the code and the team's identity live decides most choices.
- The cost of leaving is paid when there is no longer an alternative. Projects that kept their logic in scripts in the repository migrated in an afternoon; those that had it scattered through the YAML took weeks. Section 10.
- The 2021 incident and the lesson about secrets
In September 2021, Travis CI published a security advisory about a flaw that caused secure environment variables — including tokens and keys — to be exposed in pull request builds coming from forks of public repositories. That is, the very design property that made the encrypted secret model safe was broken: that a fork cannot access them. Anyone opening a PR against an affected public repository could, with a modified .travis.yml, read secrets that should never have been within their reach.
It was identified as CVE-2021-41077, and the initial communication drew considerable criticism for its scope and detail, which led to a later, more explicit publication. The operational recommendation for any affected public project was to rotate every credential.
Four concrete lessons, none of them about Travis:
1. A secret entrusted to a third party is a secret whose security you do not control. This is not an argument against SaaS — 06-03 mentioned the 2023 CircleCI incident, with the same conclusion of rotating everything, and no platform is immune. It is an argument for assuming it will happen and designing accordingly.
2. Long-lived credentials are the problem; ephemeral ones bound the damage. A leaked permanent access key is valid until somebody revokes it, and that "somebody" usually finds out late. A fifteen-minute OIDC token limited to a specific role (03-02) is stolen material that is already good for almost nothing. This is the practical — not theoretical — reason the course insists so much on federated identity over stored secrets.
3. You have to know how to rotate everything, and have rehearsed it. The useful question is: if tomorrow your provider tells you "rotate all your credentials", how long does it take you and what breaks? If the answer is "I do not know", that is a recovery plan still to be written, exactly in the sense of 03-05. An inventory of secrets with their owner and their rotation procedure is worth a great deal on the day you need it.
4. Forks are the most delicate trust boundary in any CI. The same problem, with a different mechanism, is the pull_request_target issue of 04-03 that 06-06 will return to. The rule is universal and tool-independent: code from a fork cannot access secrets, and any flow that looks like it grants access must be examined under a microscope.
- Migrating a
.travis.yml: equivalence table
.travis.yml: equivalence tableThis is the part you will probably actually use. The translation is mostly mechanical, with two or three design decisions.
| Travis CI | GitHub Actions | GitLab CI/CD |
|---|---|---|
language: node_js |
uses: actions/setup-node@v4 |
image: node:22 |
node_js: ["20","22"] |
strategy: matrix: node: [20, 22] |
parallel: matrix: NODE: ["20","22"] |
dist: jammy |
runs-on: ubuntu-22.04 |
image: / runner tag |
os: [linux, osx] |
runs-on: [ubuntu-latest, macos-latest] |
Runners with tags |
services: [postgresql] |
services: with a container image |
services: with an image |
before_install |
Steps at the start of the job | before_script |
install |
- run: npm ci |
before_script or script |
before_script |
Preceding steps | before_script |
script |
- run: (each one cuts on failure) |
script: |
after_success |
- if: success() |
after_script or a job with when: on_success |
after_failure |
- if: failure() |
when: on_failure |
after_script |
- if: always() |
after_script |
deploy: provider |
The provider's action or run |
A deployment job with environment |
cache: directories: |
actions/cache with a key |
cache: key/paths |
env.global |
env: at workflow level |
variables: |
secure: "..." |
Repository secrets | Protected and masked variables |
stages |
needs: between jobs |
stages + needs |
allow_failures |
continue-on-error: true |
allow_failure: true |
fast_finish |
fail-fast (inverse semantics) |
Default behaviour |
if: branch = main |
if: github.ref == 'refs/heads/main' |
rules: - if: $CI_COMMIT_BRANCH == ... |
TRAVIS_COMMIT |
github.sha |
$CI_COMMIT_SHA |
TRAVIS_PULL_REQUEST |
github.event_name == 'pull_request' |
$CI_PIPELINE_SOURCE |
TRAVIS_BRANCH |
github.ref_name |
$CI_COMMIT_BRANCH |
The four decisions that are not mechanical:
- The distinction between phases that break the build and phases that do not disappears. In Travis,
after_successdoes not affect the result; in the others, a step that fails breaks the job unless you addcontinue-on-error. When migrating, decide explicitly what should remain non-blocking. scriptwith several commands does not stop at the first failure; in the others it does. If your.travis.ymlrelied on all of them running so as to collect every error at once, you have to reproduce that on purpose.secure:secrets are not migrated: they are rotated. They cannot be decrypted without the repository's key in Travis, and even if they could, moving a secret is the perfect moment to change it. And if the repository is public and was active in 2021, rotation is not optional (section 7).- Take the opportunity to introduce the graph. A one-to-one migration reproduces the waits of the sequential model. This is the moment to add
needsand gain time for free.
- A complete migration, step by step
We start from a realistic .travis.yml, of the kind you will find:
# BEFORE — inherited .travis.yml
language: node_js
node_js: [ "18", "20" ]
dist: focal
services: [ postgresql ]
cache:
directories: [ node_modules ]
env:
global:
- CI=true
- secure: "K3jd...==" # encrypted NPM_TOKEN
before_install:
- psql -c 'CREATE DATABASE app_test;' -U postgres
install:
- npm ci
before_script:
- npm run migrate
script:
- npm run lint
- npm test
after_success:
- bash <(curl -s https://codecov.io/bash)
deploy:
provider: npm
email: team@reservalia.example
api_key: { secure: "9dLp...==" }
on: { tags: true }# AFTER — .github/workflows/ci.yml
name: CI
on:
push: { branches: [main] }
pull_request:
release: { types: [published] } # 1 · replaces `on: tags`
permissions:
contents: read # 2 · least privilege (04-03)
concurrency: # 3 · did not exist in Travis
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
strategy:
fail-fast: false # 4 · we want to see both results
matrix:
node: [18, 20]
services:
postgres: # 5 · explicit version, not the image's
image: postgres:16-alpine
env: { POSTGRES_PASSWORD: test, POSTGRES_DB: app_test }
options: >-
--health-cmd pg_isready --health-interval 5s --health-retries 10
ports: [ '5432:5432' ]
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm # 6 · caches ~/.npm, NOT node_modules
- run: npm ci
- run: npm run migrate
- run: npm run lint
- run: npm test -- --coverage
- uses: codecov/codecov-action@v4 # 7
if: always()
with: { token: '${{ secrets.CODECOV_TOKEN }}' }
publish:
needs: [test] # 8 · explicit graph
if: github.event_name == 'release'
runs-on: ubuntu-22.04
permissions:
contents: read
id-token: write # 9 · publishing with provenance
environment: npm-production # 10 · approval and scoped secrets
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, registry-url: 'https://registry.npmjs.org' }
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # 11 · a NEW token, rotatedon: tags→on: release: publishing when a release is created is more explicit and avoids publishing because of an accidental tag.- Explicit
permissionsare a concept Travis did not have; use the migration to add them (04-03). concurrencycancels stale runs: it did not exist in Travis and it is an immediate saving.fail-fast: falsereproduces the usual behaviour of the Travis matrix, where seeing both results was the norm.- PostgreSQL as a container with a pinned version and a health check, instead of "whatever the image ships": more reproducible, and it removes the start-up race that in Travis was solved with retries.
- A deliberate change:
node_modulescomes out of the cache and~/.npmgoes in. It fixes the binaries problem of 04-02 that the original file was carrying. - Uploading coverage with
if: always()reproduces the semantics ofafter_successwithout breaking the build, but explicitly. needs: [test]is the structural gain: now there is a graph.id-token: writewith--provenanceadds verifiable provenance to the package (02-06 and 04-03): something that did not exist in Travis's model.environmentbrings approval and scoped secrets to the publishing step.NPM_TOKENis a new token. Thesecure:one is revoked. You do not migrate a secret: you rotate it.
Recommended phases for the migration, with the same criterion as 05-04 — each step with value of its own:
| Phase | What is done | When to move to the next |
|---|---|---|
| 1. Inventory | What the .travis.yml does, which secrets it uses, who depends on the badge |
When it is written down |
| 2. Parallel pipeline | The new workflow coexists with Travis, both on every PR | When the results match for 2 weeks |
| 3. Signal switch | The required check becomes the new one; Travis becomes informational | When nobody looks at the Travis one any more |
| 4. Retirement | Delete .travis.yml, disable the project, revoke every secret, update the README badge |
— |
What not to migrate: jobs nobody looks at, matrix entries for versions already out of support — migrating node_js: "0.10" is wasted work — and the after_* phases that have been failing silently for months. A migration is the best opportunity to delete, and wasting it means loading the debt onto the new tool.
- The portability lesson
Out of the whole Travis episode comes a practical recommendation that 06-07 will develop and that is worth taking on now:
# Fragile: the logic lives in the tool's YAML
script:
- npm ci
- npm run lint
- npx tsc --noEmit
- npm test -- --coverage --shard=$SHARD/4
- docker build -t $IMAGE:$TRAVIS_COMMIT -f apps/api/Dockerfile .
- trivy image --severity HIGH,CRITICAL --exit-code 1 $IMAGE:$TRAVIS_COMMIT#!/usr/bin/env bash
# scripts/verify.sh — the logic lives in the repository
set -euo pipefail
SHARD="${SHARD:-1}"; TOTAL="${TOTAL:-1}"
npm ci --prefer-offline
npm run lint
npx tsc --noEmit
npm test -- --coverage --shard="${SHARD}/${TOTAL}"# The YAML is left as a thin orchestrator, and migrating means rewriting 15 lines
script:
- ./scripts/verify.shThe advantages go beyond migration, and that is why it is worth doing even if you never change tools: the developer can run exactly the same thing on their machine — which eliminates the whole class of "it only happens in CI" failures — the script is tested and reviewed as code, and the YAML stops being a place to hide business logic, which was one of the antipatterns of 04-01.
The honest limit: not everything can be taken out of the YAML. Triggers, the matrix, permissions, environments, concurrency and the cache belong to each tool and cannot be abstracted away without building a layer of indirection that usually costs more than it saves. The reasonable goal is not total portability, it is that 80% of the migration effort should be rewriting orchestration rather than rebuilding logic. The projects that migrated off Travis in an afternoon were the ones with a script: make ci.
- When Travis still makes sense
Honestly and without nostalgia:
- A project that already has it, works and is not being touched. A stable
.travis.ymlin a maintenance repository is not an emergency. Migrating costs hours that might pay off better elsewhere. With two conditions: check that the secrets are still valid and necessary, and that thedist:images it uses are still supported. - Uncommon platforms and architectures. Travis kept support for architectures such as IBM Power, IBM Z and ARM64 earlier and more accessibly than others, and some systems projects still use it for that. If your problem is exactly that, it remains an option worth considering.
- Simple publishing of open source packages. The
deploy:block with predefined providers is still about the shortest thing there is for publishing to npm, PyPI or GitHub Releases.
When not to: any new project, any pipeline with real continuous delivery, any team already on GitHub or GitLab, and any case where promotion by digest or selective execution matter. It is not a matter of taste: these are structural limitations of the fixed-phase model, not fixable defects.
Common Mistakes and Tips
Migrating secrets instead of rotating them. Changing tools is the ideal moment to rotate. And if the repository is public and was active in 2021, rotation is mandatory.
Migrating one to one without introducing the graph. It reproduces the waits of the sequential model and wastes the migration's best gain: adding needs.
Copying the node_modules cache across to the new tool. It was bad practice in Travis and it still is. Cache the download directory with a key based on the lockfile.
Forgetting the README badge. It ends up pointing at an inactive service and shows a false status for months. It is cosmetic and it is the first thing anyone arriving at the project sees.
Assuming script stops at the first failure. In Travis it does not; in the others it does. If the order of the commands was hiding failures, the migration will bring them all out at once, and that is a good thing even if it stings on day one.
Leaving Travis active after migrating. It consumes credits, sends notifications nobody reads and keeps alive some secrets nobody controls any more. Disabling the project and revoking credentials is part of the migration, not an extra.
A general tip: when you inherit a .travis.yml, read it as archaeological documentation before translating it. allow_failures tells you what had been broken for a while, the list of dist values and versions tells you when it was last touched, and after_success tells you which integrations existed and perhaps no longer do. That context is worth more than the mechanical translation and it is exactly the approach of 05-04.
Exercises
Exercise 1. Translate this .travis.yml into GitHub Actions, deciding explicitly which steps should be blocking and which should not, and justify each decision:
language: python
python: [ "3.10", "3.11" ]
dist: focal
services: [ redis ]
cache: pip
install:
- pip install -r requirements.txt -r requirements-dev.txt
before_script:
- flake8 --version
script:
- flake8 .
- mypy src/
- pytest --cov=src
after_success:
- coveralls
after_failure:
- cat logs/errors.log
deploy:
provider: pypi
user: __token__
password: { secure: "Ab3d...==" }
on: { tags: true, python: "3.11" }Exercise 2. A public repository of your company used Travis between 2018 and 2022 with secure: for an AWS deployment token with broad permissions, an npm token and an SSH key encrypted with encrypt-file. Nobody has touched it since 2022. Write the response plan: what you assume, what you do first, in what order and how you verify you have finished.
Exercise 3. Marta asks: "How much would it cost us to move from GitHub Actions to another tool if their terms change tomorrow?". Design the assessment exercise over Reservalia's ci.yml: how current portability is measured, which changes would improve it, what they cost and how far it is worth going.
Solutions
Solution 1.
name: CI
on:
push: { branches: [main] }
pull_request:
release: { types: [published] }
permissions: { contents: read }
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }
jobs:
verify:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11"]
services:
redis:
image: redis:7-alpine
options: --health-cmd "redis-cli ping" --health-interval 5s --health-retries 10
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: 'requirements*.txt'
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: flake8 . # blocking
- run: mypy src/ # blocking
- run: pytest --cov=src --cov-report=xml # blocking
- name: Upload coverage
if: always() # NOT blocking
continue-on-error: true
uses: coverallsapp/github-action@v2
- name: Diagnostics on failure
if: failure() # equivalent to after_failure
run: cat logs/errors.log || true
publish:
needs: [verify]
if: github.event_name == 'release'
runs-on: ubuntu-22.04
permissions: { contents: read, id-token: write }
environment: pypi
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install build && python -m build
- uses: pypa/gh-action-pypi-publish@release/v1 # trusted publishing via OIDCDecisions and their justification. flake8, mypy and pytest are blocking: they are the quality gate of 02-05 and their failure must prevent the merge. The Coveralls upload is non-blocking with continue-on-error because an external service failing says nothing about the code — it is exactly the semantics of after_success, but now explicit instead of implicit in the phase's name. The log dump uses if: failure(), reproducing after_failure.
Two deliberate changes from the original. on: tags becomes on: release so as not to publish because of an accidental tag. And the password: secure: disappears: it is replaced by trusted publishing via OIDC with id-token: write, which removes the long-lived token entirely. It is the change that reduces risk the most in the whole migration, and it is the direct application of the lesson from section 7: the best answer to "stored secrets can leak" is not to store secrets.
A note about cache: pip with cache-dependency-path: without that second line, the key does not include requirements-dev.txt and the cache is not invalidated when the development dependencies change. It is the same incomplete-key mistake as in 04-02.
Solution 2.
What you assume, and it is the most important decision: that all three secrets are compromised. The repository is public, it was active during 2021 and it used exactly the affected mechanism. You do not need to look for evidence of exploitation before acting: the absence of evidence in years-old logs is not evidence of absence, and the cost of rotating is far lower than the cost of being wrong the other way.
Order of action, by potential impact:
- The AWS token with broad permissions (first, today). Deactivate the key — deactivate before deleting, so you can still investigate — review CloudTrail for the period in case there is activity from unexpected addresses or regions, and replace it. The replacement is not "another key": it is a role assumed via OIDC from your current tool (03-02), with minimum permissions. If there are resources only that key could touch, verify their integrity.
- The npm token. Revoke it, review the package's published versions in case there is one the team does not recognise — a malicious publication is the worst scenario, because the damage goes to the consumers — and replace it with trusted publishing via OIDC.
- The SSH key from
encrypt-file. Revoke the public key everywhere it is authorised (servers, repositorydeploy keys), generate a new one, and check theauthorized_keysof the reachable servers in case there are keys nobody recognises. - Repository cleanup. Delete
.travis.yml, the.encfile of the encrypted key and the badge. Careful: deleting does not remove the history, and the encrypted file remains accessible; that is why revocation is the real measure and deletion is only hygiene. - Disable the project in Travis so that no credentials remain stored there.
How you verify you have finished: a written inventory of the three secrets with their status — revoked, replaced, verified — a check that no production system depended on them (if something breaks on revocation, it shows up here, which is why you do it during working hours and not on a Friday), and a note in the decision log with the date and the reason. And one underlying action that prevents the next episode: build the organisation-wide inventory of secrets with an owner and a rotation procedure, because this exercise has shown it did not exist.
Solution 3. The exercise is done in three steps and produces a number, not an opinion.
Step 1: classify every line of the ci.yml into three categories.
| Category | What it is | Cost of migrating | Examples in Reservalia |
|---|---|---|---|
| Portable | Logic that already lives in scripts or standard commands | Zero | npm ci, npm test, docker build, ./scripts/deploy.sh |
| Orchestration | Pipeline structure: jobs, needs, matrix, triggers, cache |
Mechanical rewrite | on:, needs:, strategy: matrix, actions/cache |
| Tied | Depends on the tool or its ecosystem | Real rebuilding | Third-party actions/*, environment: with reviewers, OIDC towards AWS, prepare-node, reusable-build-publish.yml, GITHUB_TOKEN |
Step 2: measure. Count the lines in each category across the five workflows and estimate hours. A typical result for a repository like Reservalia: the orchestration is rewritten in 2-4 days; the tied part is the expensive one — every third-party action has to be replaced or reimplemented, the OIDC federation is redone against the new issuer, and the composite actions and reusable workflows are translated to the equivalent mechanism. If 40% of the pipeline is in the "tied" category, the migration is a matter of weeks.
Step 3: improvements and their cost.
| Improvement | Cost | Effect on portability | Worth it? |
|---|---|---|---|
Move the logic of long run blocks into scripts/*.sh and npm scripts |
2-3 days | High: turns "tied" into "portable" | Yes, and it pays for itself even if you never migrate: runnable locally, reviewable, testable |
| Replace third-party actions with the equivalent CLI commands | 1-2 days | Medium-high | Yes where the action only wraps a CLI; no where it adds real logic |
Define a make ci, make test, make build as the single interface |
1 day | High | Yes: it gives a stable contract independent of the tool |
| Abstract triggers and environments behind your own layer | Weeks | Low | No: it costs more than the migration it avoids |
How far it is worth going, which is Marta's real question: up to the point where the logic is portable and the orchestration is thin, and not one step further. That is, the first three points yes; the fourth no. The reason is expected cost: the probability of migrating in the next two years is low, but the first three points have immediate benefit regardless of any migration — local reproducibility, a more readable pipeline, reviewable and testable logic, less "false green" from differences between local and CI — whereas the fourth only pays off in an improbable scenario.
The short answer for Marta: "Today, about three or four weeks. With two or three days of work that we could do with anyway for other reasons, we bring it down to a week. And we are not going to try to bring it down to zero, because that costs more than the migration it would avoid."
Conclusion
Travis CI invented the way CI is written today — a file in the repository, convention over configuration, a version matrix — and proved that CI could be a right of any project rather than a privilege of whoever had a Jenkins administrator. Its fixed-phase model was perfect for "build and test" and structurally insufficient for continuous delivery: no graph, no passing of artifacts between jobs, no selective execution and with promotion by digest compromised. Translating the Reservalia pipeline into Travis was the most useful exercise in the module so far, not because the result is usable, but because what does not fit explains why the successors were designed with the graph at the centre.
And the three lessons that outlive the tool: your provider's business model can change and you do not decide it; a secret entrusted to a third party is a secret whose security you do not control, from which it follows that ephemeral federated credentials are not a fashion but a real reduction in damage; and the cost of changing tools is decided years before you change them, according to how much logic you left inside the YAML.
The next lesson changes category. Docker and Kubernetes are not CI tools: they are the substrate modern pipelines run on and the destination they deploy to. We will look at Docker in its two roles — the pipeline's execution environment and the format of the artifact being deployed — go down into what the course had not covered (BuildKit, layer caches in the registry, multi-platform builds, minimal images, building without a privileged daemon), and then on to Kubernetes as the destination: the minimum objects, the probes, rollback with kubectl rollout undo, Helm versus Kustomize, and why GitOps wins in Kubernetes and what changes compared with the push-based cd.yml Reservalia uses.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
