Sign in
← CI/CD: hands-on
15 min · medium · Lesson 10 of 10

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

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].

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.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson cicd-practice-10-antipatterns