One Pipeline, Three Products: Standardizing CI/CD and Test Reporting
Context
Deploy hostnames, registry addresses, and repository paths are omitted. The pipeline structure and the numbers are as they are.
A small platform team owned three codebases — the backend and frontend of Product A, plus the frontend of Product B. Each ships through development, staging, and production. That is nine deployment paths before you count the E2E, static analysis, and security scan jobs hanging off them.
The problem
Pipelines were not code
Build and deploy steps lived as scripts inside Jenkins job configuration screens. In that shape there is no change history, nothing to review, and no way back from a bad edit except a job-config backup. Every new product spread the problem further: someone opened the screen and pasted a copy of the previous script.
Stage structure differed per product
Two jobs both called "staging deploy" would have an image verification step in one and not the other, and push to the registry at different points. Anyone reading a pipeline had to read it from the top every time.
E2E was outside the trust boundary
A Cypress suite existed, but it behaved differently from machine to machine, so its result was never solid enough to gate a release on. What remained was a human walking the UI before every release. The pipeline definition itself had grown to 314 lines.
What I did
1. Moved pipeline definitions into a dedicated repository
Every Jenkinsfile moved out of the product repositories into one pipeline repository, and every Jenkins job switched to Pipeline script from SCM. What stays in the job config is a repository and a path.
pipeline/
├── backend/ daily · development · staging · production
├── frontend-a/ development · staging · production
│ + playwright · sonarQube · zap · (cypress, legacy)
└── frontend-b/ development · staging · production
+ playwright · sonarQube
Nineteen pipeline definitions are versioned there today. Pipeline changes arrive as merge requests, get reviewed, and revert with git revert. Given that deployment procedure and product code change on different clocks, this separation fit the team better than a Jenkinsfile per product repo.
2. Made the stage structure a contract between products
The staging pipelines for both frontends now follow the same sequence:
Notify Start → Clone → write .env → prepare compose file
→ Docker Build → Image Verification → Snyk Scan
→ push to private registry → update repo and .env on the deploy host
→ pull image and compose up
The two staging definitions are 352 and 350 lines. What differs is product-specific values — image name, target host, compose file — not shape. Add a stage on one side and it goes in the same slot on the other.
Production adds two things on top: automatic GitLab Release creation, and a dual image push to both the private registry and the GitLab registry, so an image is still reachable when the internal network is not.
3. Migrated E2E from Cypress to Playwright
All 28 app suites moved to Playwright. The migration was not a straight port — it also widened coverage to per-app Edit/Share/Unshare/Cancel paths and workspace cross-link checks.
| Cypress | Playwright | |
|---|---|---|
| Tests | 28 apps | 350 passed / 0 skipped / 0 failed |
| Runtime | — | ~9 min local / ~12–15 min on Jenkins |
| Pipeline definition | 314 lines | 151 lines |
Tests run inside the official Playwright image, but driving the container directly rather than through Jenkins' agent { docker { ... } }:
CONTAINER=$(docker create --ipc host \
-e PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL}" -e CI=true \
--workdir /workspace ${PLAYWRIGHT_IMAGE} \
sh -c 'yarn install --immutable && yarn test:e2e ${testArgs}; EXIT=$?; \
chmod -R 777 node_modules playwright-report test-results 2>/dev/null || true; \
exit $EXIT')
docker cp frontend/. $CONTAINER:/workspace/
docker start -a $CONTAINER || true
EXIT_CODE=$(docker inspect $CONTAINER --format '{{.State.ExitCode}}')
docker cp $CONTAINER:/workspace/playwright-report/. frontend/playwright-report/ 2>/dev/null || true
docker cp $CONTAINER:/workspace/test-results/. frontend/test-results/ 2>/dev/null || true
docker rm $CONTAINER || true
exit $EXIT_CODE
Three reasons for that shape. The workspace has to go in and the report and traces have to come back out to be archived as artifacts. Files created inside the container are owned in a way that made Jenkins cleanup fail, which the chmod prevents. And most importantly, docker start -a is allowed to fail so the real exit code is read back with docker inspect and re-raised — a failed artifact copy must not turn a failing test run into a green build, and a failing test run must not be lost behind one.
4. Selective runs with an always-on core
A full run takes 12–15 minutes. Force that on every one-app change and people start routing around the pipeline. So a Jenkins UI parameter selects which app modules to run — but the set that always runs alongside a selection is fixed in code:
def defaultNonAppPaths = [
'e2e/database/',
'e2e/workspace/libraries.spec.ts',
'e2e/workspace/targets.spec.ts',
'e2e/workspace/targets-create.spec.ts',
'e2e/app/jobs-filters.spec.ts',
]
Database search, workspace basics, job list filters — the shared paths nearly every app stands on. This is where you catch the change that fixed one app and quietly broke another through a shared component or an altered API response shape. Leave the parameter empty and the full suite runs.
5. Pulled security and quality scanning into the pipeline
Checks that used to be run by hand, when someone remembered, now sit at fixed points in the pipeline.
- Snyk scans the container image in staging, right after the build and right before the registry push, and reports critical findings to Slack.
- SonarQube runs as its own job and publishes static analysis to a shared server.
- OWASP ZAP runs a dynamic security scan against the staging environment.
All three report today; promoting them to gates that block a deploy is sequenced after the backlog of critical findings is cleared — a permanently red gate is a gate the team switches off.
Results
| Metric | Before | After | Change |
|---|---|---|---|
| Manual regression | ~5 hours per release | ~1 hour per release | −80% |
| Automated E2E | Local, varies by machine | 350 tests, 12–15 min in CI | reproducible |
| E2E pipeline definition | 314 lines (Cypress) | 151 lines (Playwright) | −52% |
| Where pipelines live | Scattered in Jenkins job config | 19 files in a dedicated repo | version controlled |
| Staging stage structure | Different per product | Identical across both frontends (352 / 350 lines) | standardized |
| Vulnerability scanning | Manual, when remembered | Automatic scan on every staging build | continuous |
Manual testing time fell because the same ground is now covered automatically, not because less of it is covered. The time freed up moved to what automation still does not reach — exploratory testing, and actually reading what the full runs caught.
Found this case study interesting?
Always up for a conversation — whether or not there's a project attached.