[CODE]

Building a Site-Agnostic Ads Engine: Conversion Verification in CI

How I split per-site config from shared engine logic for a multi-account Google Ads automation pipeline, added conversion-tracking route verification that runs in CI, and moved keyword cleanup into reviewable YAML diffs.

4 min read
typescript automation github-actions google-ads

I run paid acquisition for a few unrelated businesses — a couple of B&Bs (cometrue-bnb, super-inn) and a restaurant (tonytony). They share nothing about their products, but they share almost everything about the mechanics of running Google Ads: pulling search terms, promoting BROAD match, de-duping keywords, checking that conversion tracking actually fires. So this batch of work was mostly about drawing the line between what is per-site and what is engine.

The split that mattered

The repo layout enforces the boundary. Anything site-specific lives under sites/<name>/ — a site-config.yaml, a business-context.md, the generated ads-changes/*.yaml. Everything in src/engine/ and scripts/ knows nothing about any specific account; it takes a config and a customer ID and operates.

The payoff showed up immediately when I built conversion-tracking verification. The original version had tonytony’s tracking quirks baked into the context markdown — useful for a one-off audit, useless as a repeatable check. The commit feat(engine): conversion route verification (CI auto-run, site-agnostic) pulled that into src/engine/conversion-verify.ts. Now the verification logic is one function; what differs per site is config.

The thing I underestimated: conversion tracking breaks silently. An ad keeps spending, clicks keep coming, and the conversion column just quietly reads zero because a redirect chain swallowed the gtag fire or a thank-you page route changed. You don’t get an error. You get a slow bleed. So the verification walks the actual conversion route — the path a real user takes to the event — and asserts the tracking pixel is reachable at the end of it. Running it in CI means a route regression surfaces in a workflow run instead of three weeks later in a spend review.

Why everything became YAML diffs

The d15fba09 commit (BROAD upgrade + keep_one dedup automation) is where the cleanup work landed, and the important decision there isn’t the dedup heuristic — it’s that nothing mutates the account directly. The scripts emit ads-changes/*.yaml files: cleanup-*.yaml, volume-*.yaml, pending-*.yaml. A separate ads-mutate.ts applies them.

This is deliberately a two-phase commit. Generating a change is cheap and reversible; applying it spends money and touches a live account. By making the intermediate a checked-in YAML file, I get a diff I can read before anything happens, and I get a record of exactly what was applied after. The pending- prefix is the human gate — those are proposals that haven’t been approved. restore super-inn weekly report (2026-06-20 Wayne explicitly agreed) is the same instinct showing up in the commit message itself: destructive or money-spending actions get an explicit human ack, and I write down that the ack happened.

The keep_one dedup is the unglamorous part. Across rounds of keyword expansion you accumulate the same term in BROAD, PHRASE, and EXACT across multiple ad groups. The naive fix deletes duplicates; the correct fix keeps exactly one — and which one you keep depends on match-type priority and which ad group is actually performing. That logic is worth automating precisely because doing it by hand is where mistakes happen.

The dispatch UI nobody asked for but I needed

The smallest commit is my favorite: weekly-report dispatch changed to checkbox UI (single/multi/all). The weekly report workflow runs across multiple sites. Originally it was all-or-nothing. When I’m debugging one site’s report I don’t want to regenerate four. GitHub Actions workflow_dispatch supports type: boolean inputs, which render as checkboxes, so I exposed one per site plus an “all” toggle. It’s a five-line YAML change that removed a recurring annoyance — exactly the kind of friction that compounds when you touch a workflow daily.

What I’d flag

The context markdown files (ads-health-context.md, business-context.md) are doing double duty as both documentation and prompt input for the AI-assisted analysis. The refactor(ads-health): two shared contexts lifted into engine commit was me starting to untangle that — extracting the genuinely reusable analytical framing out of the per-site notes. It’s not done. Right now a context file mixes ‘how this account works’ with ‘how the engine should reason’, and those drift apart at different rates. That’s the next boundary to draw.

The overall pattern here — site config as data, engine as code, mutations as reviewable artifacts, money-spending actions gated on explicit acks — is the only thing that’s kept a multi-account ads operation from turning into a pile of one-off scripts I’m afraid to run.