Antipatterns: five ways to make a pipeline useless
A green job that lies, a password in the log, latest instead of a version, a monolithic job, and a dead only. Each one shown on an example.
Why study the bad stuff
Building a pipeline that works isn't hard. The harder part is noticing when it's running idle: it runs, it goes green, and it doesn't protect you from anything. Here are five such cases, each one a regular in other people's configs.
The full bad example lives in the course repo at lesson-10-antipatterns/bad-pipeline.yml, with
the fixed version next to it in pipeline.yml.
1. A green job that lies
green-lie:
image: node:22.11.0-alpine
script:
- node -e "throw new Error('test failed')" || true
- echo "job finished successfully"
This job's log:
$ node -e "throw new Error('test failed')" || true
Error: test failed
at [eval]:1:7
$ echo "job finished successfully"
job finished successfully
Status Passed, time 9 seconds. The test failed, the error is right there in the log, and the
pipeline is green.
The runner decides a job's fate by the exit code of each script line. Tacking on || true turns
any exit code into 0, so the runner never sees the failure. People add it to get past something
"for now": flaky tests, a linter complaining about legacy code. Then everyone forgets about it, and
the CI becomes a decoration that's always green.
If a check genuinely can't be blocking yet, there's an honest way to do it:
lint:
script:
- npm run lint
allow_failure: true
The difference matters. || true hides the failure, while allow_failure shows it: the job still
shows as failed, with an orange warning icon next to it, and the pipeline moves on. You can see
exactly what didn't pass, and turn the check back into a blocking one once it's fixed.
2. A secret in the config
- 'echo "Prod password: hunter2"'
In the log:
Prod password: hunter2
Two separate sins here. First: the password lives in .gitlab-ci.yml, which means it's in git,
forever, in the history, for everyone who cloned the repo. Second: it gets printed to the log, and
job logs are visible more widely than people assume, and they stick around for months.
The right way was covered in the lesson on variables: the value goes into Settings → CI/CD → Variables with Masked visibility, into the command through stdin, never into the log.
A word on removing it. Deleting the password in a commit isn't enough: it stays in the history. A leaked secret is treated as compromised: you revoke it and issue a new one, you don't just hide it.
3. latest instead of a version
image: node:latest
The latest tag moves. It points at the latest Node release, and when a new major version comes
out, the tag moves onto it. A build that passed yesterday fails today, there's not a single commit
between them, and someone spends an hour hunting through their own code for the cause.
Short tags have the same problem, just milder. node:22-alpine stays on version 22, but it still
moves inside it: in the fall of 2024 it pointed at Node 22.11.0, by October 2026 it's already
22.23.3. Only an exact tag (node:22.11.0-alpine) fully pins the environment, and the most
reliable option is the image's digest (node:22.11.0-alpine@sha256:...): that always pulls the
exact same image.
The same goes for images you build yourself. myapp:latest in a registry means "no idea what":
there's no way to tell what's in prod right now, and no way to roll back.
Pin the build environment to an exact tag, say node:22.11.0-alpine, and tag your own images
myapp:$CI_COMMIT_SHORT_SHA. A tag tied to a commit turns a rollback into running a previous image.
4. One job for everything
deploy-everything:
script:
- npm ci
- npm test
- npm run build
- echo "deploying to prod"
This technically works. It has three problems.
No feedback: the job list just shows "job failed", and whether it was the tests or the deploy is only clear from the log.
An expensive restart: to retry the deploy, you have to reinstall dependencies and rerun the tests from scratch.
No parallelism: the linter and the unit tests could run at the same time, but they're stuck inside one sequential command.
Split the steps into stages, as covered in the lesson on stages: test, build, deploy. Each
job gets its own status, its own timing, and the ability to restart just the one that needs it.
5. only living next to rules
mixed:
script:
- echo hi
only:
- main
rules:
- if: $CI_COMMIT_BRANCH
The pipeline doesn't even start:
Unable to run pipeline
jobs:mixed config key may not be used with `rules`: only
This is a typical result of editing someone else's config: someone added rules on top of the old
only and never removed it. The whole pipeline breaks, not just one job: a config error cancels
the entire run.
only/except and rules are incompatible inside the same job. When you rewrite a job to use
rules, remove only completely.
How the fixed version looks in full
stages:
- test
- build
- deploy
test:
stage: test
image: node:22.11.0-alpine
script:
- npm ci
- npm test
build:
stage: build
image: node:22.11.0-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist
expire_in: 1 day
deploy:
stage: deploy
script:
- echo "Deploying version $CI_COMMIT_SHORT_SHA"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
The tests block the build, the build hands off an artifact with a limited lifetime, the deploy only runs from the default branch and only by button, the version is tied to the commit, and there's no secret in the file.
deploy has no image, so it takes the runner's default one. In your own project, set an image
there too, with an exact tag.
Common mistakes
|| trueorallow_failure: trueon tests "for now". A month later, nobody remembers that the tests don't block the deploy.- The secret got removed from
.gitlab-ci.ymlin the next commit, and everyone relaxed. It's still in the history and in old logs; it needs to be revoked and replaced. latestgot swapped for22-alpineand everyone assumes the version is pinned. A short tag moves too, just less often.- The monolith got split into stages, but
deploybuilds the project again. The deploy should take the finished build from the artifact, or something other than what the tests checked ends up in prod. rulesgot added to an old job andonlystayed. The pipeline doesn't get created at all.
What to remember
|| true makes a check decorative; when failure is a deliberate, acceptable choice, write
allow_failure: true instead.
A secret in .gitlab-ci.yml stays in git history forever: you change it, you don't delete it in a
commit.
latest and short tags move: pin the exact image version, and tag your own images by
CI_COMMIT_SHORT_SHA.
One job for everything costs you a clear status, a cheap restart, and parallelism.
only and rules in the same job take down the whole pipeline while the config is being parsed.
That's the end of the practical track. You now have a pipeline with stages, a containerized build, caching, secrets and a manual deploy: the skeleton of what people actually run at work.
What you'll need for the tasks
In the labs, a training runner on your own laptop runs the pipeline. Like a GitLab Runner with the
Docker executor, it runs every job in a clean container, goes through the stages in order, and only
passes files between them through artifacts. A manual job waits for a button. The runner works
without network access and only takes the images alpine:3.20.3 and python:3.12.7-alpine, which
setup/up.sh downloads. The project's variables live in setup/ci-variables.txt, and the runner
replaces Masked values in the log with [MASKED].
git -C work commit -am "...": the runner, like GitLab, only reads what's committed.bash run-pipeline.sh: runs the pipeline for the current branch's latest commit.bash run-pipeline.sh --play deploy: the same, with the manual jobdeploy's Run button pressed.git -C work switch -c <branch>: see howrulesbehaves off ofmain.- GitLab puts a job with no
stagekey into stagetest. Jobs in the same stage don't wait for each other: if one fails, the others still finish, only the next stages don't start. allow_failure: true,stages,artifacts:pathsandartifacts:expire_in,variables,ruleswithifandwhen: manual.ls -R artifacts/: what actually made it into the artifact.
The third lab is a mini-project covering the whole module: a site pipeline from scratch.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson cicd-practice-10-antipatterns
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress