Skip to content
Back to Writing

CI Checks Linters Can't Do: API Contract Checks, Mobile-Pattern Checks, Warning Ratchets, and Tests That Pin Stripe Bugs

Byte Smith · · 12 min read

ESLint was green. PHPStan was green. TypeScript compiled. The test suite passed. And the “Pay online” button on every invoice Zenpresa sent for several months did nothing when clicked, because the route that button pointed at had never been registered. The controller existed. The frontend called the right path. No tool in the pipeline was looking at both sides at once.

This post is about the checks we added to continuous integration after bugs like that one: bugs that were syntactically perfect, type-correct, and wrong. None of them is a linter. All of them run on every pull request in the same GitHub Actions workflow as the linters, and each one exists because a specific bug shipped.

The API-contract check

The invoice payment bug had a simple shape. The React app had a function that built a URL for the online-payment endpoint. The Laravel backend had a controller method that handled it. The route file that should have joined them had the line for a different payment endpoint, and the two had drifted during a refactor. Every layer was internally consistent; the gap was between layers.

The fix was a script that looks at both sides. It walks the frontend’s API client and extracts every path it can call. It asks the backend to list its registered routes. Then it compares them: every path the frontend calls must resolve to a registered route with a compatible method, and every route intended for the frontend must have at least one caller or an explicit “used externally” annotation.

The script is under two hundred lines and runs in seconds. It is not a full contract-testing framework, and it deliberately does not try to validate request or response shapes; that is what typed API clients and feature tests are for. It answers one question: does every button point at something that exists? In the months since, it has failed pull requests four times, each one a route that would otherwise have shipped dead.

The mobile-pattern check

Two defects shipped repeatedly on mobile, and both were valid JSX that no linter could object to.

The first was wrapper clipping. A data table inside a container with horizontal overflow hidden, on a narrow screen, silently loses its right-hand columns. The action buttons on invoices and appointments live in the rightmost column. On a phone, they did not exist. This shipped three times in three different tables, because each time it was a new component written by someone who tested on a laptop.

The second was hover-only controls. A row that reveals its edit and delete buttons on hover is a normal desktop pattern. Touch screens do not hover. The buttons never appeared, and the feature was invisible to every user on a phone, which for a service business owner between appointments is most of them.

The check is a small script that parses the frontend’s component files and flags two patterns: a table or wide element inside a wrapper whose classes hide horizontal overflow without an inner scroll container, and an element whose only visibility trigger is a hover variant with no focus or always-visible fallback. It is a heuristic, it has an allow-list for the handful of legitimate cases, and it produces false positives occasionally. It has also caught both bugs before merge every time since it was added, which is the only metric that matters.

The lint-warning ratchet

The frontend lint job used to fail on any error and ignore warnings. That meant warnings accumulated, because nothing stopped them. When we tried the obvious fix, failing on any warning, the job went red on the first run with a count in the hundreds, nobody had time to clear them, and within a week the job was marked “expected to fail” and ignored entirely. Zero was the wrong target.

The ratchet is the right one. The workflow records the current warning count in a file, with the date it was set. The lint job fails if the count goes up. Anyone can lower the number when they clear warnings, and lowering it is encouraged in the pull-request template. Nobody can raise it. The count has fallen steadily since, not because anyone scheduled a cleanup, but because every engineer who touches a file with a warning tends to fix it while they are there, and the ratchet guarantees that work is never undone.

The same ratchet applies to PHPStan’s baseline on the backend and to the count of @ts-expect-error comments. The principle is general: when the current state is far from the goal, gate on the direction of travel, not on the destination.

Pinning Stripe destination-charge bugs with tests

Zenpresa uses Stripe Connect so that customers pay the business directly and the platform takes a fee. Three bugs shipped in that integration, each subtle, each now pinned by a test that fails if it ever comes back.

Scoping Stripe.js to the connected account. When a customer pays an invoice, the payment element in the browser must be initialised for the business’s connected account, not the platform’s. Initialised for the platform, the payment succeeds and the money lands in the wrong place. The test renders the payment page for a tenant with a connected account and asserts the client-side initialisation carries the connected-account identifier.

Webhooks with no account field. Stripe sends events for connected accounts with a field identifying which account they belong to. Events for the platform’s own account do not carry that field. The webhook handler assumed the field was always present, and platform-level events crashed the handler, which meant the platform’s own subscription events were dropped. The test replays both kinds of event through the handler and asserts both are processed.

Platform-scoped refunds. Refunding a destination charge from the platform’s context, rather than the connected account’s, produces an error or, worse, a refund against the wrong balance. The test issues a refund for a connected-account charge and asserts the request is made with the connected account in scope.

The larger lesson from those three bugs reshaped how the payment flow works. The webhook is the source of truth. When Stripe says a payment succeeded, that is the event that marks the invoice paid. The browser’s “payment confirmed” callback, which arrives first and feels authoritative, is treated only as an accelerator: it triggers a check that may mark the invoice paid early, but nothing depends on it arriving. Both paths converge on a single compare-and-set: mark paid only if currently unpaid, in one atomic statement, so the accelerator and the webhook can race and the invoice is still marked paid exactly once. A test drives both paths in both orders and asserts one transition.

Test the branch that deploys

The earliest version of the workflow ran on pull requests and on pushes to the main branch. Staging deployed from a different branch, and that branch was not in the trigger list. For a while, staging ran code that CI had never seen, because a hotfix pushed straight to the staging branch skipped every check.

This is embarrassingly simple to fix and embarrassingly common. The workflow now triggers on every branch that any environment deploys from, and a comment in the trigger list names the environment each branch feeds. If a branch deploys, it is tested. There are no exceptions, including for the person who wrote the workflow.

Where each check lives

The pipeline is eleven jobs, and the checks above slot into it rather than adding new top-level stages:

  • Backend tests, across a matrix of PHP versions, including the Stripe tests and the tenant-isolation tests from the database-per-tenant post. The AI assistant’s unhappy-path tests from the tool safety post run here too.
  • Backend code style and backend static analysis, the latter with the ratcheted baseline.
  • Frontend type check, frontend lint with the warning ratchet and the mobile-pattern check, frontend tests, and frontend build. The API-contract check runs in the build job, because it needs both the built frontend and the backend’s route list.
  • Embeddable widgets, type-checked and built separately because they ship to third-party pages.
  • Docker build validation, which builds the production image the same way the deploy does and fails if the Dockerfile has drifted from what the app needs.
  • Dependency audit against known vulnerabilities in both Composer and npm trees.
  • Static application security testing over the whole repository.

Everything except the backend test matrix finishes in a few minutes. The whole pipeline is the gate for merging to any deploying branch. The structure follows the pattern from our React SPA monorepo CI/CD post; the supply-chain jobs follow the software supply chain security post; and the securing AI coding-agent workflows post explains why the same pipeline is the gate for code written by agents, not just humans.

Cost vs coverage

Each check above is cheap to run and was expensive to not have. The contract check and the mobile-pattern check together take under a minute. The ratchet is a file comparison. The Stripe tests run against a fake provider and take seconds.

What they cost is the writing: a few hours each, usually the afternoon after the bug was found, while the shape of the failure was still fresh. That is the pattern we would recommend to any team. When a bug ships that the pipeline should have caught, do not only fix the bug. Ask what check would have caught it, write that check the same day, and make it fail on the commit before the fix. A pipeline built that way is a record of every way the product has actually broken, and it does not let the same thing break twice.

The series that started with the Zenpresa launch is a story about a small team shipping a platform with a lot of surface area: many tenants, many modules, an agent that can act, and payments that must be right. The pipeline is how a small team gets to sleep. If you are building something with a similar shape, the checks in this post are a good afternoon’s work each, and the product they protect is open for a free trial if you want to see what they hold together.

FAQ: CI checks beyond linting

What is an API contract check in CI?

A script that extracts every endpoint path the frontend can call, lists the backend’s registered routes, and fails the build if any frontend call has no matching route or any frontend-intended route has no caller. It does not validate request or response shapes; it only guarantees that every button points at something that exists. It exists because a payment button shipped pointing at an unregistered route.

How do you enforce mobile-friendly patterns in React automatically?

With a heuristic script over component files that flags two patterns: wide elements inside wrappers that hide horizontal overflow without an inner scroll container, and controls whose only visibility trigger is a hover variant with no focus or always-visible fallback. It has an allow-list and occasional false positives, and it has caught both real bugs before merge every time since it was added.

What is a lint-warning ratchet?

A recorded warning count that the lint job compares against. The build fails if the count goes up; anyone may lower it. It replaces a “zero warnings” gate that was unachievable and therefore ignored. The same approach applies to static-analysis baselines and suppression comments. Gate on direction of travel when the destination is far away.

How should a payment flow decide an invoice is paid?

Treat the payment provider’s webhook as the source of truth and the browser’s confirmation callback as an accelerator that may mark the invoice paid early but that nothing depends on. Both paths converge on one atomic compare-and-set so that the invoice is marked paid exactly once regardless of which arrives first. Pin every previously shipped bug with a test that replays the exact scenario.

Why should CI run on staging branches, not just the main branch?

Because any branch that deploys somewhere is production for someone. If staging deploys from a branch that CI does not trigger on, hotfixes pushed to it skip every check. List every deploying branch in the workflow trigger and name the environment each one feeds.