# The deploy line (FRE-1695) Written 2026-11-09 by the CTO. Supersedes the implicit assumption in every earlier freno-dev landing report that "merged on Gitea" meant "live". ## The defect Production `freno.me` and its subdomains are built by Vercel from **GitHub `mikefreno/freno-dev`**. Agents merge into **Gitea `Mike/freno-dev`**. The two masters diverged at merge-base `dd368d2`: | Line | Head at filing | Ahead of merge-base | | --- | --- | --- | | Gitea `Mike/freno-dev` master | `a8ce226b` (2026-11-09 16:16Z) | 12 commits — FRE-371, FRE-366, FRE-202, FRE-997, FRE-1670 | | GitHub `mikefreno/freno-dev` master | `4e412ede` (2026-11-09 15:40Z) | 2 commits — NOOK-1037, NOOK-1038 | Every Gitea merge since `dd368d2` was undeployed, including the FRE-371 client-IP fix and the whole Nessa `/terms` page. Measured on the live site at 2026-11-09T20:46Z: `/nook/agents` served `Agent coverage` (the GitHub line's title) and `/terms` returned 404 (the Gitea line's page). No agent run can push to GitHub — `/paperclip/.git-credentials` holds `git.freno.me` entries only — so the divergence could not be closed from the agent side. ## Decision **Gitea `Mike/freno-dev` is the canonical repository. GitHub `mikefreno/freno-dev` is a mirror, and it is the tree Vercel builds.** One line, one direction: Gitea master → GitHub master → Vercel → live. ### Why this shape and not the alternatives - **Vercel cannot build from Gitea.** Vercel's git integrations are GitHub, GitLab and Bitbucket; a Gitea host is not a supported provider. So "point Vercel at the repo agents push to" is not a setting that exists. Even with a Gitea-hosted mirror on GitLab, the deploy repo would still be a mirror. - **Agents can only push to Gitea.** The per-agent credential is Gitea-only (`git.freno.me`), and every review/CI path the company runs — the PR relay (FRE-653, FRE-1680), the `--checks` sweep (FRE-1364), the verdict rows — is Gitea-shaped. Making GitHub canonical would mean re-issuing GitHub credentials to every agent and rewiring all of that. Keeping Gitea canonical keeps the authoring side exactly as it is. - **Deploying from CI instead of a mirror** (Gitea Actions → `vercel --prod`) is the third option and it is worse: freno-dev has no workflow files today, Gitea Actions cannot run in this installation (FRE-1267), and it would need a Vercel token on a runner. A mirror needs one credential, in one place, and no new CI. ### Reconcile first, then mirror The mirror must fast-forward. The two GitHub-only commits are sponsor-authored landing work that is live today (NOOK-1037 refresh, NOOK-1038 scoped-remote restore); they cannot be dropped. So the GitHub line is merged **into** Gitea master first: git merge gh/master # merge commit, no history rewrite After that merge, GitHub master is an ancestor of Gitea master, and a plain `git push` (no `--force`) is a fast-forward. The mirror is configured without force-push so that any GitHub-side drift fails loudly instead of being silently overwritten. Two conflicts were resolved in the merge, both copy contracts: 1. `src/components/nook/FeatureBreakdowns.tsx` — took NOOK-1038's refreshed island bullets and kept FRE-997's `18 agents` bullet (the roster on /agents lists all eighteen; `agents.test.ts` asserts equality). 2. `src/routes/nook/remote-claims.test.ts` — one file holding both contracts. NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote hold: scoped one-click setup for Tailscale and authorized known hosts is a supported claim, blanket promises stay banned. FRE-997 (2026-11-04) stands: eighteen is publishable, stale thirteens stay banned. The only NOOK-1038 line dropped is its repetition of the older "no coverage count" clause, which FRE-997 superseded when it completed the roster. ## How a merge becomes a deploy 1. An agent merges a reviewed PR into Gitea `Mike/freno-dev` `master` (or Mike merges it in the Gitea UI). Nothing else changes for the engineer. 2. A **Gitea push mirror** on `Mike/freno-dev` pushes `master` to `https://github.com/mikefreno/freno-dev.git` with sync-on-commit enabled. 3. Vercel sees the GitHub push and builds production from GitHub `master`. 4. `scripts/verify-deploy.sh` confirms it: same SHA on both remotes, and the live site serving copy that only exists on the reconciled line. **Who can trigger a deploy:** anyone who can merge a PR into Gitea master — every engineer, plus Mike. No agent needs a GitHub credential and no human action is needed per deploy once the mirror exists. The manual fallback is a Vercel redeploy by Mike, which is only useful after step 2 has moved GitHub master. **Who must set it up once:** the repo owner on Gitea (`Mike` — the agent token has `push` but not `admin`, so an agent cannot create the mirror). The operator runbook is on the FRE-1695 operator row; the short version is: create a fine-grained GitHub token limited to `mikefreno/freno-dev` with contents read/write, add it as the push mirror's credential in Gitea repo settings, tick "Sync when commits are pushed", then run `scripts/verify-deploy.sh`. Until that one-time step lands, GitHub master is behind and the site is stale — exactly as it is today. ## Failure modes this deliberately keeps loud - **A commit made directly on GitHub master** is not in Gitea. The mirror push then rejects (non-fast-forward) and Gitea shows the mirror error. Do not commit to GitHub `master`; land it in Gitea and let it mirror. - **A merge that is only a Gitea merge** is not live until the mirror runs. Check `scripts/verify-deploy.sh` before calling a site change published. - **The first mirrored deploy switches on a cron that deletes production rows.** `vercel.json` carries two crons. `/api/the-nook/lifecycle` (13:00 UTC) is already on the tree Vercel builds today, so it runs. `/api/analytics-retention` (12:00 UTC, added in `1d9287a`, FRE-369) is only on Gitea master: the first deploy that follows the mirror starts calling it, and the next 12:00 UTC runs `DELETE FROM VisitorAnalytics WHERE created_at < datetime('now','-365 days')` against the production database. Not staged, not reversible — whatever is older than the window is gone the first time it fires. The window is `ANALYTICS_RETENTION_DAYS`, and unset, non-positive, or over 730 each resolve to the 365-day default (`src/server/analytics-retention.ts`). 365 is the conservative end — picked so that switching the schedule on is not itself a data-loss event — but conservative still means *over a year old gets deleted*. Set the window in Vercel **before** the first mirrored deploy if 365 is not what you want. The cron call is authorized with `Authorization: Bearer ` (`src/routes/api/analytics-retention.ts`), and the endpoint fails closed: with no secret configured every call is a 401, so a missing secret is a table that quietly stops being pruned, never an unauthenticated bulk delete. Vercel supplies `CRON_SECRET` to its own crons and production already runs the 13:00 job, so assume the secret is there and it runs. - **The FRE-371 fix's actual exploitability in production** depends on how the edge sets `x-forwarded-for`/`cf-connecting-ip`. That is measured and gated by the Security Engineer, not assumed here. ## Verifying scripts/verify-deploy.sh It is read-only and needs no credentials — that is what makes it safe to run from an agent run as well as by the operator, and it is why the checks stay read-only. It checks both remotes' master SHA, that `https://freno.me/` answers a direct 200, the live Nook/Nessa copy, and (when the commit is present locally) that `2fd2a4a` is an ancestor of the deployed commit. The root probe is there because every other check can stay green while the root deploy is broken. It is status-only, and it deliberately does not probe `https://freno.me/terms`: `SITEMAP_ROUTES.main` in `src/lib/sitemap-routes.ts` has no `/terms` — Terms of Use is a Nessa surface (`nessa.freno.me/terms`), so a 404 on the root domain's `/terms` is the design, not a defect.