Files
freno-dev/docs/deploy-source.md
agent-senior-engineer 91bedd915d docs(deploy): name the retention cron the first mirror fires, and probe the root (FRE-1707)
Two findings from the FRE-1695 review (approved at df788cb6).

1. The mirrored deploy activates a destructive cron the deploy docs never
   mentioned. vercel.json carries two crons: /api/the-nook/lifecycle (13:00
   UTC), already on the tree Vercel builds, and /api/analytics-retention
   (12:00 UTC, 1d9287a / FRE-369), which exists only on Gitea master. The first
   deploy that follows the mirror starts calling it, and the next 12:00 UTC
   deletes every VisitorAnalytics row older than the window —
   ANALYTICS_RETENTION_DAYS, 365 by default — irreversibly and unstaged. The
   behaviour is intended; the silence was not, because the operator is the one
   who presses the button. docs/deploy-source.md "Failure modes" now names the
   cron, the ANALYTICS_RETENTION_SECRET ?? CRON_SECRET bearer check, the 365-day
   default, the fails-closed-without-a-secret path, and that the first run after
   enabling prunes at that default.

2. scripts/verify-deploy.sh could read green with a broken root deploy: it
   probed nook.freno.me, nessa.freno.me/terms and the two remotes, never
   freno.me itself — and FRE-1695's acceptance names the root domain. Adds a
   ROOT_HOST (default https://freno.me) status check ahead of the subdomain
   probes: expects a direct 200, no -L, so a redirect reads as a failure. It
   deliberately does not probe https://freno.me/terms — SITEMAP_ROUTES.main has
   no /terms; Terms of Use is a Nessa surface, so a root /terms 404 is the
   design. The script stays read-only and credential-free.
2026-11-10 03:02:57 +00:00

8.0 KiB

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 <ANALYTICS_RETENTION_SECRET ?? CRON_SECRET> (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.