$ cat ci-cd-anti-patterns.md

Three CI/CD anti-patterns we keep seeing.

· 5 min read · ci/cd

Each of these patterns looks reasonable in isolation — sometimes even responsible. They're the ones that get added when something goes wrong. Then they cause the next thing that goes wrong.

1. The long-lived feature branch with manual deploy

You can spot it in the repo before you log into the CI:

$ git branch -r --sort=-committerdate | head
  origin/feature/checkout-rewrite      # 47 days old, 312 commits ahead
  origin/feature/new-billing           # 23 days old, 89 commits ahead
  origin/main

The story is always the same. Someone got burned by a bad release, so a rule was made: nothing merges until QA signs off. Branches grow. Conflicts grow. The "QA pass" becomes a multi-day ritual. Merging the branch becomes scarier than the original release that caused the rule.

The fix isn't bravery, it's flags. Merge to main daily, even when work is incomplete. Hide unfinished features behind a feature flag — environment variable, LaunchDarkly, OpenFeature, whatever fits. Production runs the new code; nobody sees it until you flip the switch.

This is trunk-based development, and the only reason most teams resist it is that they don't trust their tests. Which is a different problem — and you can't fix it by hiding from it on a branch.

2. Deploy-on-tag

The pipeline triggers on git tag:

on:
  push:
    tags:
      - 'v*'

It feels disciplined. It's also a trap.

Tags are created by humans — usually one human, late on a Friday, who tags v1.4.7, pushes, and watches the deploy go red. The fix isn't on main yet. The bad tag is now history. Untagging it doesn't untrigger the deploy. The next tag has to be v1.4.8, which is now the third release this week, and version numbers stop meaning anything.

Worse: the artifact you deploy is whatever the build job produces from the tagged commit. If the build is non-deterministic — different versions of dependencies, different base image — what runs in production isn't what was tested.

The fix. Build once, on every commit to main. Promote that artifact through environments. Deployment becomes "point this environment at this image digest" — a config change, not a build.

main commit  →  build  →  artifact (digest pinned)
                            ↓
                    deploy to staging  ←  automatic
                            ↓
                    deploy to prod     ←  click, or auto if green

The same bytes that ran in staging run in prod. Tags become labels for humans, not triggers for machines.

3. Tests that depend on each other

You see the symptom before you see the cause:

The cause is shared state. One test creates a user named "[email protected]"; another asserts that user exists; a third deletes them. Run them in any other order and the world breaks.

This is the most expensive anti-pattern of the three because it eats trust. Once a developer learns that "sometimes CI is flaky", they stop looking at red builds. Real failures slip through.

The fix. Every test owns its setup and teardown. Every test uses unique data — UUIDs, factory-bot patterns, transactional rollbacks. If a test depends on something existing, it creates that thing.

Then turn on parallel execution and shuffle order to enforce it:

# pytest
pytest -p random_order --random-order-seed=$RANDOM -n auto

# jest
jest --shuffle --maxWorkers=4

# go
go test -shuffle=on -parallel 4 ./...

The first run will be brutal. That's the point.

The pattern under the patterns. All three of these start as a response to fear — fear of a bad release, of a botched deploy, of a flaky test. The fix is never to add ceremony around the fear. It's to remove the fear by making the dangerous thing safe.

If your pipeline has any of these, you already know it. Get in touch — we untangle CI/CD setups for a living.