# ADR-0043 — Every change on main deploys to production, on Node 26 (/docs/maintain/adr/ADR-0043-every-change-on-main-deploys-to-production-on-node-26)

- **Status:** Approved
- **Date:** 2026-11-05
- **Deciders:** [@puncoz](https://github.com/puncoz)
- **Approved by:** [@puncoz](https://github.com/puncoz)
- **Approved on:** 2026-11-05
- **Supersedes:** [ADR-0042](./ADR-0042-only-main-deploys-straight-to-production-on-vercel.mdx) in part: decision 2's
  `ignoreCommand`, and decision 5's Node 24
- **Superseded by:** —
- **Design documents:** [`../sequence/build-and-search-sync.mdx`](../sequence/build-and-search-sync.mdx),
  [`../architecture/system.mdx`](../architecture/system.mdx), `../security/`. Nothing a reader sees changes, so there is no app flow
  (ADR-0008).

## Context [#context]

- ADR-0042 stopped previews twice: `git.deploymentEnabled` gave branches other than `main` no deployment, and an `ignoreCommand`,
  `[ "$VERCEL_ENV" != production ]`, cancelled any deployment that still started outside production.
- On GitHub, Vercel's status for both merges to `main` since then (`30cbb13`, PR #5; `d2752fa`, PR #6) is **"Canceled by Ignored Build
  Step"**. The merges before `vercel.json` existed (`82ef827`, `e2f4ca7`) show "Deployment has completed". Nothing on `main` has
  deployed since.
- The owner confirmed that the production branch is `main` and that *Automatically expose System Environment Variables* is on. Why
  `VERCEL_ENV` was not `production` in the cancel step cannot be seen from this repository. The command printed nothing, and the
  build log is in the owner's Vercel team.
- The owner's requirement: **a deployment starts if and only if something changes on `main`** (a push, a merge or a commit), and it goes
  straight to the custom domain. There are no previews and no staging domain.
- Read from Vercel's documentation (November 2026):
  - `git.deploymentEnabled` is Vercel's setting for which branches trigger a deployment. A branch that matches several rules deploys when
    at least one of them is `true`.
  - The production branch's deployments are production deployments, and the domains assigned to Production move to each new one.
  - Without an `ignoreCommand`, the dashboard's *Ignored Build Step* applies. Its *Automatic* behaviour builds every deployment that
    starts.
- The owner moved `engines.node` to `26.x` (commit `29978dc`), Vercel's default, and asked for it to be recorded as approved.
  `scripts/sync-content.ts` is plain TypeScript run with Node's type stripping, which Node 26 has.

## Decision [#decision]

1. **`git.deploymentEnabled` alone decides what deploys.** `vercel.json` keeps `{ "**": false, "main": true }` and has **no
   `ignoreCommand`**. Every commit that lands on `main` (pushed, merged or committed on GitHub) starts a production deployment, which
   Vercel assigns to the custom domain. Nothing else in `vercel.json` can skip or redirect it: its keys are only `$schema`, `git`,
   `installCommand` and `buildCommand`. The dashboard's *Ignored Build Step* is *Automatic*.
2. **A deployment that starts anywhere else cannot publish.** This happens when someone authorizes a fork's pull request under Git Fork
   Protection (a fork's branch named `main` matches the rule), or when someone runs a `vercel` CLI deploy. Preview and Development hold
   no variables (ADR-0042 decision 6), so `scripts/sync-content.ts` refuses (ADR-0042 decision 7). The build fails, and neither the
   index nor the domain changes.
3. **Node is 26.** `"engines": { "node": "26.x" }` in `package.json`.

The rest of ADR-0042 stands: the history check, Bun 1.4.4 with `--frozen-lockfile`, Production-only variables, the publish guard,
Git-only production deploys, revert to roll back, no tests in the build, protections on, and the owner's domain.

## Options considered [#options-considered]

| Option | Pros | Cons | Verdict |
|---|---|---|---|
| `deploymentEnabled` only; the publish guard stops anything else from publishing | Vercel's own branch control; nothing extra can cancel `main`; a stray build fails loudly instead of in silence | An authorized fork build or a CLI deploy runs `next build` before the sync refuses | **Chosen** (owner) |
| Keep `ignoreCommand` and find out why `VERCEL_ENV` was not `production` | Two rules, as ADR-0042 had | `main` stays undeployed until the cause is found from the owner's build logs | Rejected |
| `ignoreCommand` on the branch, `[ "$VERCEL_GIT_COMMIT_REF" != main ]` | Does not read `VERCEL_ENV` | Reads the same system variables, so it may fail the same way; a fork's branch is also `main` | Rejected |
| Node 26.x | Vercel's default; what the owner chose | Not the Node on the owner's machine (v24) | **Chosen** (owner) |
| Node 24.x | Matches the owner's machine | Not the owner's choice | Rejected |

## Consequences [#consequences]

**Positive**

- Every change on `main` reaches the custom domain with no cancel step in between.
- One rule to read in `vercel.json`, and it is the one Vercel documents for this.

**Negative / accepted trade-offs**

- A deployment that starts outside production is no longer cancelled before it builds. It fails at the sync instead and still counts
  against the deployment quota. Not authorizing fork deployments, and not running the `vercel` CLI, keeps this from happening.
- A pull request from a fork's `main` (the owner's own workflow) shows Vercel's "Authorization required to deploy" on its head
  commit. This is Git Fork Protection, and that deployment is not wanted.
- The owner's machine runs Node 24, and Vercel runs 26. Local checks run on 24.

## Content invariants [#content-invariants]

- **§6.1 Private stays private:** unchanged. Only `main` publishes, the same pages as before.
- **§6.5 Every page is readable as Markdown:** unchanged.

## Security considerations [#security-considerations]

- **What can publish** is unchanged: a commit on `main`. The write key is still in Production only. A build that starts outside
  production, including an authorized fork's code, runs with no variables, so it has no write key and cannot write the index.
- **Fork code in a build.** If a fork deployment is authorized, the fork's code runs during `next build` with the Preview environment,
  which is empty. This is the same as under ADR-0042, which cancelled it only once the build had started. Git Fork Protection stays on.
- **Residual risk:** a failed, protected preview deployment exists if one is authorized. Its URL needs a Vercel login (Standard
  Protection).

## Implementation notes [#implementation-notes]

- [x] `vercel.json`: `ignoreCommand` removed. `$schema`, `git.deploymentEnabled`, `installCommand` and `buildCommand` unchanged.
- [x] `package.json`: `"engines": { "node": "26.x" }` (the owner's commit `29978dc`).
- [x] `src/lib/vercel-config.test.ts` checks four things: there is no `ignoreCommand`; `vercel.json` has only the four keys and `git`
      has only `deploymentEnabled`; under Vercel's matching rule, `main` deploys and `dev`, `feature/search`, `main-old`,
      `release/main`, `Main` and `staging` do not; and `package.json` pins Node 26. The `ignoreCommand` tests are gone.
- [ ] Not testable here: Vercel deploying the next commit on `main` to the custom domain. It is checked by the first push after this
      change.
- [x] `bun run test`, `bun run types:check` and `bunx next build` pass. `bun run lint` still fails with "typescript-eslint does not
      support TS 7.0".

## Docs to update on implementation [#docs-to-update-on-implementation]

- [x] `content/docs/maintain/sequence/build-and-search-sync.mdx`: the trigger and the first failure path.
- [x] `content/docs/maintain/architecture/system.mdx`: the `vercel.json` table and Node 26.
- [x] `content/docs/maintain/security/secrets.mdx`: `VERCEL_ENV` is no longer read by `vercel.json`.
- [x] `content/docs/maintain/security/threat-model.mdx` and `checklist.mdx`: no `ignoreCommand`; Node 26.
- [x] `diagrams/docs/build-and-search-sync.{json,html}`: Node 26.
- [x] `AGENTS.md` §1 and §5, `README.md`'s Deploy section.
- [x] `content/docs/maintain/adr/index.mdx`: the row and the next free number. ADR-0042's *Superseded by*.
