nsadesigns.
← All receipts
ReceiptsCI/CD

Merging to main stopped meaning production

Before
merge = prod
After
merge = UAT gate
Noah Adams

Merging a pull request to main used to put code in front of customers. On 2026-07-31 that stopped being true on two sites: merging to main now deploys a UAT environment behind a regression gate, and production ships only when a person clicks Promote to Production. It took one evening. Here is what it cost and what it broke.

Before

There was one path to production and it was the merge button. When CI stalled, the deploy went out by hand with wrangler pages deploy, and a manual wrangler deploy bakes in whatever .env.local holds. On 2026-06-18 that put Clerk dev keys into production for about 4 minutes (measured), logged the same day in the project context file. PR previews had the mirror image problem: indexable, and carrying the production Clerk publishable key. How many minutes each manual deploy cost is not measured, so there is no number for it here.

What we did

Four pull requests on the platform repo, #64 to #67, on the evening of 2026-07-31. Claude Code drove the rollout; every PR was reviewed and merged by hand.

PR #64 made the app host aware. NEXT_PUBLIC_SITE_URL now drives canonicals, robots, JSON-LD and Open Graph, and non-prod builds are noindexed three ways: meta, robots.txt, and a runtime header.

PR #65 was the pipeline: a nsadesigns-uat Cloudflare Pages project with its own D1 database, deploy.yml retargeted from production to UAT, and a new promote.yml that runs only on manual dispatch. The GTM container id fallback was deleted, so nothing outside the promote workflow can load analytics.

PR #66 stripped every analytics origin out of the Content Security Policy on non-prod builds.

PR #67 added an --env uat tier to the nsa-regression suite with inverted publishing expectations: noindex required, disallow required, a pk_test Clerk key required. It runs as the post-deploy gate on every merge, and the verdict that night was GO.

Around them: one Cloudflare Access application in front of both UAT hosts (email one-time codes for people, a service token for CI) and a Clerk dev instance, so UAT never touches production auth. fivefootfinds shipped the same shape in PR #15 that evening: remote UAT D1 migrations, a production-content sync that excludes subscriber PII, and a guarded ISR bake.

After

Merging to main deploys uat.nsadesigns.com and runs the regression gate. Production ships only through the manual Promote to Production workflow on the Actions tab. Both repos work that way now.

Timestamps from git log: the four platform PRs merged between 18:02 and 20:42 CDT, 2 hours 50 minutes (measured) from the first commit to the last merge, and fivefootfinds PR #15 merged 37 minutes later (measured). The diff was 30 files and +340/-78 (measured) across five commits on platform, and 17 files and +536/-43 (measured) on fivefootfinds, per git show --stat. New infrastructure: one Pages project, one worker, two UAT D1 databases, a KV pair, one Cloudflare Access app, one zone WAF rule.

What broke

The Google tag gateway. Cloudflare serves it first party at /vtse/*, zone level, not scoped by hostname, so UAT traffic was beaconing into production GA4 with no GTM container id in the build and every analytics origin stripped from the CSP. We watched it happen. A zone WAF custom rule blocking /vtse/* on the UAT host stopped it, and that rule was a dashboard step done by hand.

Two more turned up the same evening. PR previews had been indexable and had been baking the production Clerk publishable key, fixed en route with per-environment GitHub secrets for preview, uat and production. A Cloudflare Access policy with an Allow action that lists a service token does not authenticate that token: the action has to be Service Auth. That one ate debugging time.

Some of what is still manual is manual on purpose. The Promote click is deliberate: one person, one dispatch, one deploy. Required-reviewer protection on the production environment needs GitHub Pro on a private repo, which is not enabled, so the dispatch is the gate rather than an approval screen. The WAF rule and the Access app were dashboard work, and token rotation and least-privilege scoping are still open. On fivefootfinds, UAT binds the production photos R2 bucket, so an admin upload done on UAT writes into production storage.

What you can copy

  • Split the two meanings of merge. Merge to main should mean this is the candidate; a separate, named, manual action should mean customers can see it. In GitHub Actions that is two workflow files: one on push to main, one on workflow_dispatch.
  • Make staging prove it is staging. Invert the checks in the post-deploy gate so it fails when the non-prod host is indexable, when robots.txt allows crawling, or when the auth key is not a test key. A gate that only knows production expectations will pass a leaky UAT.
  • Watch the network from the new environment before you trust it. The analytics leak came from a zone-level product no code change could reach, and the only thing that found it was looking at what UAT actually talked to.

NSA Designs publishes these as receipts: one shipped thing, before and after, and what broke. If splitting merge from ship is on your list, that is AI Readiness Sprint work.