Model: Claude Code (2 parallel sessions) | Date: 2026-11 | Status: fixed, rule added
A security fix was live for a few minutes, then a different session's git push deployed an older tree over it. The admin panel on one of five apps was open again. Nothing errored. Both deploys reported success.
Setup
One pnpm monorepo, five Next.js apps on Cloudflare Pages, one CI workflow. The relevant part:
on:
push:
branches: [main]
jobs:
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# matrix: all 5 apps, every push Every push to main builds and deploys every app in the matrix. Pages keeps one "latest production deployment" per project. Whichever deploy finishes last wins.
I run several Claude Code sessions against this repo. Session A was doing a security pass on the admin API: an auth gate on /admin and /api/admin, then an encoded-slash bypass fix. Session B was doing unrelated content work and pushing independently.
What happened
Timeline from git log, same day:
| Time (UTC) | Event |
| 12:51 | Session B pushes a content-generator fix. CI deploys all 5 apps from it. |
| 13:07 | Session A commits the admin gate locally (first of 11 commits that day before the next push). |
| after 13:07 | Session A runs scripts/deploy.sh by hand. Gate is live. |
| later | The CI deploy of the 12:51 push lands on top. Gate gone. |
The 12:51 tree has no gate commits. They existed only in session A's working copy. CI checks out origin/main, not my disk. The last deploy to finish was the older tree, so the unauthenticated admin API, including a settings endpoint that returned stored provider keys, was reachable again.
The manual deploy output said success. The CI run said success. Both were true.
Why nothing flagged it
- Pages deploys are not ordered by commit time. They are ordered by completion time.
deploy.shverifies the deploy it just made. It cannot know a queued CI run will replace it.- CI runs lag. The test job, then the build job (
turbo --filter='...[origin/main]'), then deploy. A run triggered earlier can finish after a manual deploy that started later. - A verification curl made right after a manual deploy proves nothing about the next 10 minutes. The check has to happen after CI settles.
The rules now
# 1. before any manual deploy: be on top of origin/main
git fetch origin && git rebase origin/main
# 2. after: confirm origin/main contains the commits you deployed
git branch -r --contains <your-sha> # must list origin/main
# 3. wait ~10 min, then check what Pages actually serves
wrangler pages deployment list --project-name=<app> # Source column = commit
curl -s -o /dev/null -w '%{http_code}\n' https://<app-host>/api/admin/settings # expect 401 Rule 2 is the important one. A critical fix that is not on origin/main is not deployed. It is borrowed time until the next push by anyone.
For security fixes the answer is to push them, so that CI builds the same code that is already live. The gate commits did reach origin/main later the same day, after which every CI deploy carried them. Until then the exposure window was whatever the gap was between the CI overwrite and my noticing.
The three bad assumptions
- "Deploy success means the fix is live." It means the fix was live when that command finished.
- "My local commits are the repo." CI sees
origin/main. Anything unpushed is invisible to it, and CI will happily deploy a tree that lacks it. - "Parallel sessions don't interact." They share one remote and one deploy target. Two sessions that never touch the same file still collide at the Pages project.
The failure needs no merge conflict and no failing test. It needs two actors and a deploy target with last-writer-wins semantics.
Side finding
The same week, next build failed once with Cannot read properties of null (reading '1'). That is next/font failing to fetch Google Fonts at build time, not a code error. Retrying the app fixed it. Self-hosting the fonts (next/font/local) removed the dependency on a network call inside CI.
What I'd change
A path filter or per-app deploy trigger would stop an unrelated content push from redeploying the security-sensitive app. I haven't done it because the turbo --filter already skips unchanged builds, yet the deploy job still runs for the whole matrix. Next step: make the deploy job consume the affected list, and add a post-deploy smoke test that hits the gated endpoint and fails the run on anything but 401.