ADR-0042 — Only main deploys, straight to production on Vercel
The site is built and published by Vercel from main only: no preview deployments, one production build that also updates the search index.
- Status: Approved
- Date: 2026-11-05
- Deciders: @puncoz
- Approved by: @puncoz
- Approved on: 2026-11-05
- Supersedes: —
- Superseded by: ADR-0043 (in part: decision 2's
ignoreCommand, and decision 5's Node 24); ADR-0044 (in part: decision 5'sinstallCommand) - Design documents: on implementation
../sequence/build-and-search-sync.mdx,../architecture/system.mdx,../dfd/system.mdx,../security/. Nothing a reader sees changes, so there is no app flow (ADR-0008).
Context
- ADR-0041 decision 1 made Vercel the host and left how the
site deploys there to this ADR. Nothing about deployment is in the repository yet: no
vercel.json, noengines. bun run buildisnext build, thenscripts/sync-content.ts, which replaces the live Algolia index with this build's records. A Vercel preview deployment, which by default runs thebuildscript for every branch push and pull request, would overwrite the live index with unmerged work.- The owner's choice: no previews; the one build is the production build.
- Read from Vercel's documentation (November 2026):
git.deploymentEnabledinvercel.jsontakes branch globs (minimatch). When a branch matches several rules and one istrue, it deploys. A branch that matches onlyfalserules creates no deployment.ignoreCommandruns when the deployment starts building, with the build-time system variables (VERCEL_ENVamong them): exit0cancels the build, exit1continues. A cancelled build still counts against the deployment quota.- A build clones with
git clone --depth=10.VERCEL_DEEP_CLONE=trueasks for a full clone; Vercel does not document it, but projects that use it report it works. - Builds use Node.js 26.x by default;
engines.nodeinpackage.jsonoverrides the dashboard. Bun is pinned by running the install asbunx bun@<version> install. - The
VERCELandVERCEL_ENVvariables exist only while Automatically expose System Environment Variables is on.
- The sitemap's
lastmodandarticle:modified_timecome fromgit log(lastModified: trueinsrc/lib/source.ts, ADR-0035 decision 5). In a shallow clone every file untouched by the last ten commits is dated to the clone's oldest commit.architecture/system.mdxalready says the hosting ADR must clone in full. scripts/sync-content.tsrefuses to run withoutSITE_URL, but reads the Algolia variables with non-null assertions, so a missing key reaches the Algolia client asundefined(sequence/build-and-search-sync.mdx).- Consuming repositories push their pages into this repository through the submodule (ADR-0010), so their pushes land here too.
Decision
- Only
mainis deployed, and only to production. Vercel's production branch ismain. Every push tomainbuilds the site and, in the same build, updates the search index. No other branch, pull request or fork gets a deployment. - Previews are stopped twice, in
vercel.json, so the rule is reviewed in the repository and not only in a dashboard."git": { "deploymentEnabled": { "**": false, "main": true } }: a push to any other branch creates no deployment."ignoreCommand": "[ \"$VERCEL_ENV\" != production ]": any deployment that still starts outside production (a fork's pull request, avercelCLI deploy, a redeploy of an old preview) is cancelled before the build runs, so the sync never runs.
- The build is the owner's own command.
"buildCommand": "sh scripts/require-full-history.sh && bun run build": the samebun run buildas on the owner's machine,next buildthen the sync. No second build script exists. - The build clones in full, or fails. The owner sets
VERCEL_DEEP_CLONE=truein Production. Because Vercel does not document it,scripts/require-full-history.shchecksgit rev-parse --is-shallow-repository. If the clone is shallow, the script exits1, namingVERCEL_DEEP_CLONEand this ADR, and nothing is built or published. - The toolchain is pinned to what the owner runs.
"installCommand": "bunx bun@1.4.4 install --frozen-lockfile": Bun 1.4.4 (AGENTS.md§5), frombun.lockexactly. A lockfile that does not matchpackage.jsonfails the install."engines": { "node": "24.x" }inpackage.json, sincescripts/sync-content.tsruns on Node 24.
- Variables live in Vercel's Production environment only. That means
SITE_URL,NEXT_PUBLIC_ALGOLIA_APP_ID,NEXT_PUBLIC_ALGOLIA_SEARCH_KEY,NEXT_PUBLIC_GA_MEASUREMENT_ID,VERCEL_DEEP_CLONEandALGOLIA_WRITE_KEY. The last is marked Sensitive, so it cannot be read back from the dashboard. Preview and Development hold none. Automatically expose System Environment Variables stays on (ADR-0041, and decision 2 above). - The publish step refuses without its secrets.
scripts/sync-content.tschecksSITE_URL,NEXT_PUBLIC_ALGOLIA_APP_IDandALGOLIA_WRITE_KEYbefore reading anything. It names every missing variable, never prints a value, and exits non-zero.- The check is a pure function in
src/lib/publish-env.ts, tested. The script imports it by a relative path. - A failed sync fails the build, so the deployment fails and the previous one stays live. The site and the index are never left half-published.
- Production deploys come only from Git. No
vercel --prodfrom a machine: it would publish the local working tree, uncommitted pages included. This is a working rule inAGENTS.mdandPROJECT_AGENTS.md;vercel.jsoncannot enforce it. - Going back is a revert on
main. Agit revertpushed tomainrebuilds the site and the index together. Vercel's Instant Rollback restores pages but not the index, so it is for emergencies only and is followed by that revert. - The build runs no tests and no lint. This is the owner's "direct production build". Running tests before publishing, and CI in general, is a later ADR.
- Vercel's protections stay on, set by the owner in the dashboard:
- Deployment Protection: Standard Protection, so only the production domain is public and generated deployment URLs need a Vercel login.
- Build Logs and Source Protection, so
/_logsand/_srcneed a login. - Git Fork Protection.
- The domain is the owner's. They add the custom domain in Vercel and set
SITE_URLto the samehttpsorigin. The production*.vercel.appaddress still serves the site; every page's canonical link points atSITE_URL(ADR-0035), so search engines index only the domain.
Options considered
| Concern | Option | Pros | Cons | Verdict |
|---|---|---|---|---|
| Previews | None; production only | The live index is only written by main; one environment to set up | No preview URL to check a change before it is live | Chosen (owner) |
| Previews | Previews run next build only, without the sync | A URL for every branch | Preview search shows the live index, not the branch; a second build command to keep in step | Rejected (owner) |
| Previews | Previews sync to a separate preview index | Search works on previews | A second Algolia index and key, and a preview index readable with a public key | Rejected |
| Stopping previews | deploymentEnabled plus ignoreCommand in vercel.json | Reviewed in the repo; non-main pushes cost no build; the backstop covers forks and CLI | Two rules to read | Chosen |
| Stopping previews | Dashboard Ignored Build Step: "Only build production" | No file | Invisible to review; a cancelled build per push counts against the quota | Rejected |
| Stopping previews | ignoreCommand only | One rule | Every branch push creates and cancels a deployment | Rejected |
| Where it builds | Vercel builds from Git | Direct; no deploy token anywhere | Nothing runs before the build | Chosen (owner) |
| Where it builds | GitHub Actions builds and runs tests, then vercel deploy --prebuilt | Tests gate the publish | A Vercel token in GitHub; a CI ADR first | Later ADR |
| Tests | bun run test && … in the build command | A failing test stops the publish, at a few seconds' cost | Not "direct" | Not chosen; it is one line to add later |
| History | VERCEL_DEEP_CLONE=true, and the build fails on a shallow clone | Correct lastmod; a failure is loud | Relies on an undocumented variable; a full clone grows with the history | Chosen |
| History | git fetch --unshallow in the build | Documented git | Needs read access to the remote; a private repository needs a token in the build | Rejected |
| History | Accept the shallow dates | Nothing to do | Most pages dated wrong in the sitemap, against ADR-0035 | Rejected |
| Bun | bunx bun@1.4.4 install --frozen-lockfile | Same Bun as the owner; the lockfile is binding | Bun is fetched from npm on each build | Chosen |
| Bun | Vercel's default Bun | No pin | A version Vercel does not state | Rejected |
| Node | engines.node 24.x | The version the sync script runs on | Moved by hand | Chosen |
| Node | Vercel's default, 26.x | Newest | Not run here yet | Rejected |
Consequences
Positive
- The live search index is only ever written by a build of
main, which is the risk ADR-0041 named. - The deployment is described in the repository, in
vercel.jsonandpackage.json, and each rule there is tested. - The site and the index move together. A failed sync, a missing secret or a shallow clone leaves the previous deployment live.
Negative / accepted trade-offs
- Pushing to
mainpublishes. This applies to consuming repositories too. Nothing checks a change on a live URL before readers see it; checking happens locally withbunx next buildandbun run start. The owner may protectmainon GitHub (required reviews); that is a GitHub setting, outside this repository. - No tests run before publishing (decision 10).
- For the minute or two between the sync and Vercel making the new deployment live, search can find a page that is not live yet, or miss one that is about to go.
VERCEL_DEEP_CLONEis undocumented. If Vercel drops it, builds fail (decision 4) until another way to get the history is decided.- Every build downloads Bun 1.4.4 from npm (
bunx) and clones the full history.
Content invariants
- §6.1 Private stays private: unchanged. Every page in
content/is public, and this ADR publishes exactly whatbun run buildpublishes today, frommainonly. No branch's unmerged pages reach the site or the index. - §6.5 Every page is readable as Markdown: unchanged; the production build is the same build.
Security considerations
- Secrets.
ALGOLIA_WRITE_KEYmoves from the owner's.env.localinto Vercel's Production environment, marked Sensitive. It is readable by the build and by no deployment's runtime code, since nothing outsidescripts/sync-content.tsreads it. People on the Vercel team who can change environment variables can replace it, but not read it back. The script prints only a record count, never a value (decision 7). Rotation:security/secrets.mdx. - Who can publish. Anyone who can push to
mainon GitHub, or change the Vercel project, can publish to the site and the search index. Before this ADR, only someone holdingALGOLIA_WRITE_KEYcould write the index. Accepted. GitHub branch protection is the owner's to set. - Forks and pull requests never build (decision 2), so a fork's code never runs with the Production variables. Git Fork Protection stays on as well.
- Supply chain.
--frozen-lockfileinstalls exactlybun.lock. Bun itself is fetched as the exact versionbun@1.4.4. A compromised dependency would run during the build, withALGOLIA_WRITE_KEYin its environment. This is the same exposure as the owner's machine today, now on every push. - What leaves the system. Vercel receives the full git history and the built site. Algolia receives the same records as now. No new third party is added: Vercel was adopted in ADR-0041, Algolia earlier.
- Generated deployment URLs need a Vercel login (Standard Protection), so an old deployment cannot be reached by guessing its URL.
- Residual risk: the window in which search and the live site disagree (Consequences), and the undocumented clone variable.
Implementation notes
-
vercel.jsonat the root:$schema,git.deploymentEnabled,ignoreCommand,buildCommand,installCommandas in decisions 2, 3 and 5. -
scripts/require-full-history.sh: exits1on a shallow clone, namingVERCEL_DEEP_CLONEand ADR-0042; exits0otherwise. -
package.json:"engines": { "node": "24.x" }. -
src/lib/publish-env.ts:missingPublishVariables(env), returning the names of the unset ones amongSITE_URL,NEXT_PUBLIC_ALGOLIA_APP_IDandALGOLIA_WRITE_KEY, withpublishRefusalandreadPublishEnvon top.scripts/sync-content.tscallsreadPublishEnvfirst, so it throws on any of them, naming them, before readingstatic.json. Its non-null assertions are gone. -
tsconfig.json:"allowImportingTsExtensions": true, by the owner's choice on 2026-11-05. Node needs the.tsin the script's relative import, and the type check refuses it without this. It changes type checking only:noEmitis on, and Next.js compiles with SWC. - Tests:
src/lib/publish-env.test.ts: all variables set; each one missing; empty and blank strings; no values in the message.readPublishEnvreturns the values or throws.scripts/sync-content.tsrun under Node without its variables exits non-zero, names them, never prints a given value and never reachesstatic.json.src/lib/vercel-config.test.ts:vercel.jsonhas the rules above, andpackage.jsonpins Node 24;- its
ignoreCommand, run withsh, exits1forVERCEL_ENV=productionand0forpreview,development, empty and unset; require-full-history.shexits0in a full clone,1in a shallow clone of a two-commit repository made in a temporary folder (namingVERCEL_DEEP_CLONEand ADR-0042), and1outside a git repository.
- Not testable here: Vercel honouring
vercel.jsonandVERCEL_DEEP_CLONE. Not done yet: nothing has been deployed. It is verified in the first production build's log, which should show the install with Bun 1.4.4, the history check passing andsearch updated: N records, and by one push to a branch creating no deployment. -
bun run test(534 pass),bun run types:checkandbunx next buildpass.bun run lintstill fails with "typescript-eslint does not support TS 7.0".
Docs to update on implementation
-
content/docs/maintain/sequence/build-and-search-sync.mdx: the trigger is a push tomain; Vercel's install, history check, build and sync, then promotion; failure paths for a shallow clone, missing variables and a failed sync. Its Mermaid and Archify diagram change together. The data-flow and system-architecture Archify views are regenerated with their Mermaid. -
content/docs/maintain/architecture/system.mdx: a Deployment section, and constraints where the hosting ADR is no longer pending and the full clone is decided. -
content/docs/maintain/dfd/system.mdx: GitHub to the Vercel build, and the Vercel build to Algolia withALGOLIA_WRITE_KEY, as boundary crossings. -
content/docs/maintain/security/secrets.mdx: where each variable is set (Vercel Production), andVERCEL_DEEP_CLONE. -
content/docs/maintain/security/threat-model.mdx: push tomainas a publish path; build-time supply chain with the write key. -
content/docs/maintain/security/checklist.mdx: Vercel hosting checks (decisions 2, 6, 11). -
AGENTS.md:- §1: how the site is deployed.
- §3 and §5: a push to
mainpublishes; novercel --prod. - §5: the
vercel.jsonrow. - §10:
vercel.jsonandscripts/require-full-history.sh.
-
PROJECT_AGENTS.md: a push to this repository'smainpublishes at once, site and search. -
README.md: a Deploy section with the owner's Vercel settings. -
.env.example:VERCEL_DEEP_CLONE, described as Vercel-only. -
content/docs/maintain/adr/index.mdx: status.
ADR-0041 — Readers are counted by Vercel, and by Google with consent
The site adds Vercel Web Analytics and Speed Insights, cookieless, and Google Analytics 4 with Consent Mode, a privacy banner and a footer link to change the choice.
ADR-0043 — Every change on main deploys to production, on Node 26
Vercel deploys every commit on main to the custom domain with no cancel rule in between, and the build runs on Node 26.