Soomin Hwang
Platform engineering / DevSecOps

One Pipeline, Three Products: Standardizing CI/CD and Test Reporting

Role
CI/CD Pipeline Design & Operations
Timeline
Apr 2025 – Oct 2026
Stack
JenkinsGroovy (Declarative Pipeline)DockerPlaywrightSnykSonarQubeOWASP ZAP
Disclosure
Internal infrastructure
Manual regression
~5 hours~1 hour−80%
E2E pipeline definition
314 lines (Cypress)151 lines (Playwright)−52%

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.

CypressPlaywright
Tests28 apps350 passed / 0 skipped / 0 failed
Runtime—~9 min local / ~12–15 min on Jenkins
Pipeline definition314 lines151 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

MetricBeforeAfterChange
Manual regression~5 hours per release~1 hour per release−80%
Automated E2ELocal, varies by machine350 tests, 12–15 min in CIreproducible
E2E pipeline definition314 lines (Cypress)151 lines (Playwright)−52%
Where pipelines liveScattered in Jenkins job config19 files in a dedicated repoversion controlled
Staging stage structureDifferent per productIdentical across both frontends (352 / 350 lines)standardized
Vulnerability scanningManual, when rememberedAutomatic scan on every staging buildcontinuous

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.