ci-config · git:20261026.45fce3e · 2026-10-26 · sha256 d30841bb8d266ca8

ci-config git:20261026.45fce3eA

Immutable. This exact content never changes and is served at /api/v1/blob/d30841bb8d266ca8.

---
description: CircleCI and GitHub Actions conventions - Jest worker pinning, esmodule relevance flag, workspace trimming
globs: .circleci/**, .github/workflows/*.yml
alwaysApply: false
---

# CI configuration

## CircleCI (`.circleci/config.yml`)

- Jest `--maxWorkers` is pinned per job to the `resource_class` vCPU count (large = 4, medium = 2) because docker containers report the host's CPUs via `os.cpus()`. Exception: the ReactNative `unit_tests` run is deliberately uncapped — its suites are fake-timer-wait dominated and capping workers flakes 5s test timeouts.
- Jobs halt via the `halt-unless-relevant-change` command based on flags computed once in `setup` (`.ci-esmodule-relevant`, `.ci-tests-relevant`) and transported via `save_cache`/`restore_cache` so jobs can halt before paying `attach_workspace`. Key that cache on `CIRCLE_WORKFLOW_ID`, never the SHA: caches are immutable and master fast-forwards to the queue's exact commit, so a SHA key hands master the queue's flags. Missing/unreadable flags fail open (jobs run), except in `tree-passed` (`on-missing: halt`). On the default branch both flags are always true (a push may carry several commits). The diff uses `--no-renames` so moving a file out of a relevant dir still counts.
  - `esmodule` (validate-esmodule-browser-build, esmodule-types*): a denylist, so new paths fail open. False only when every changed path is provably outside the esmodule jobs' inputs: the shared `DOCS_ONLY` paths (also the `tests` denylist) plus `.vscode/`, `plans/`, root `__tests__/` (excluded by every `tsconfig.compile.json`), `eslint.config.mjs`, `jest.config.js`, `examples/*.md`, and examples the jobs never build (`benchmark`, `benchmark-react`, `coin-app`, `nextjs`, `normalizr-github`, `normalizr-redux`, `test-bundlesize`, `vue-todo-app`). Only add a path if no esmodule job (or the `setup` builds feeding them) reads it.
  - `tests` (lint, typecheck, unit_tests, node_matrix): false only when every changed path is docs/website/tooling (`website/`, `docs/`, `.changeset/`, `.cursor/`, `.agents/`, `.claude/`, `.github/`, root `*.md`), except `website/src/components/Playground/` and `website/src/components/motion/` (have unit tests). When both flags are false, `setup` halts before install.
- Tree dedupe: the last job, `tree-passed`, requires every job and saves a `ci-passed-v1-<HEAD^{tree}>` marker. On master and `gh-readonly-queue/*`, `setup` halts every job when it finds one, so master skips what the queue already ran on the same tree. PR branches ignore markers.
  - `ci/circleci: tree-passed` is a required check (so the queue can't merge before the marker is saved); keep its name stable. Its `requires` must list every other `validation` job except `setup`, or trees get marked passed without the missing one; `setup`'s first step fails the pipeline (via `yq`) when they differ.
  - It marks only full runs (both flags true), never fork PRs. Never pass secrets to fork PRs: with cache write access a fork could plant a marker master trusts.
- Legacy TS types (`ci:build:legacy-types`, consumed by `esmodule-types`):
  - Built inside `setup` (`ci:build:setup:esmodule`) only when the esmodule flag is set; there is no separate job, to keep a job hop off the critical path.
  - CI builds the endpoint, normalizr and rest legacy outputs, all for TS >= 4.0 (the minimum supported TS, and the oldest in the `esmodule-types` matrix). `use-enhanced-reducer` still ships a `ts3.4` build in release builds (`build:types`).
  - `scripts/build-legacy-types.sh` builds each TS version concurrently; each `ts<version>/` gets the downleveled `lib` (with `abstract new` rewritten to `new` below 4.2, which `downlevel-dts` misses), then every newer version's `src-*-types` overlay, then its own. Keep overlays to small single-purpose modules (like `NoInfer.ts`, `tupleTypes.ts`) so whole-file copies can't go stale.
  - `esmodule-types` also runs `examples/todo-app/tsconfig.typetest-libcheck.json` (`skipLibCheck: false`, `types: []`) so errors inside the legacy outputs fail CI, and `esmodule-types-latest` runs it with `--moduleResolution bundler` (TS 7 removed `node`) to cover `lib/`; the other typetests use `skipLibCheck: true`.
  - `esmodule-types` also runs `scripts/check-dts-parse.mjs`, which parses every declaration the matrix TypeScript loads from each package's `typesVersions` entry points, so syntax newer than that TS fails even on entry points no typetest imports. `tsconfig.typetest-vue.json` runs only on TS >= 4.5, the oldest Vue's own types parse on.
  - Any change to legacy types building must leave `ts*/` output byte-identical to master (diff it) unless it intentionally changes published types (then add a changeset).
- `typecheck` also runs `yarn check:typeperf` ([scripts/typeperf](../../scripts/typeperf/README.md)), which reads the `ci:build:types` output from `setup`'s workspace.
- Never `git fetch --depth` the base branch in the relevance check: a shallow fetch severs the merge base and the three-dot diff fails.
- Changing root `package.json` `workspaces` requires updating the `setup` job's workspace trimming step.
- `setup`'s workspace leaves out the yarn cache (`.yarn/cache`) to keep its upload short. Jobs that re-resolve dependencies (`yarn up`/`add`) run `restore-yarn-cache`, keyed on `.ci-deps-key`, a hash of the manifests as committed (taken before trimming and `yarn up` rewrite them).

## GitHub Actions (`.github/workflows/`)

- Workflows install only needed workspaces via `./scripts/ci-install.sh [extra-workspace ...]`.
- Every workflow sets top-level `permissions: contents: read`; a job that writes declares its own `permissions` (job-level replaces the top-level set, so list `contents` too).
- `agent-rules.yml` runs `scripts/agent-rules.mjs --check` and the agent hook tests (`node --test '.cursor/hooks/*.test.js' '.claude-plugin/*.test.mjs'`; a bare directory runs nothing on Node 22) with no install (node and git only). Its push `paths` must cover every input and output of that script, `.cursor/hooks/`, and everything the Claude Code plugin's tests read (`.claude-plugin/README.md` lists it).
- `regenerate.yml` reruns `yarn copy:websitetypes` and `yarn build:skills`, fails if their committed output changes (CI never pushes fixes), then runs `yarn check:doc-examples`. Its push `paths` must cover every input of `scripts/copywebsitetypes.sh`, `skillReferences.mjs`, `scripts/pack-skills.mjs --check` (`.agents/skill-packages.json`) and `checkExamples.mjs`, including the editor types, `Playground/DesignSystem/index.ts` (global components), `Playground/monaco/typeCheckFlags.json` (the editor's compiler flags) and the root `package.json`. It needs the `website` workspace (e.g. `bignumber.js`), which CircleCI's `setup` drops.
- `site-preview.yml` keeps the preview's critical path to the build:
  - `build` (`website build`) builds the site and decides the deploy. `checks` (`website checks`) runs everything else beside it: types, typecheck, website lint (CircleCI's `lint` covers only `packages/*/src`), translation and blog-date checks, script tests. `website`, the required check, passes when both pass or the gate skipped them.
  - The preview deploys after `build` alone; production also waits for `checks`. `needs` waits even for a job whose `if` then skips, so the two are separate callers (`preview`, `production`) of the reusable `docs-deploy.yml`.
  - On pull requests, `.github/actions/website-install` restores `node_modules`, and both jobs restore the packages' `lib` with its types, from caches whose keys must cover every input of `ci-install.sh website` and `ci:build:website`. The merge queue and master never restore them. `checks` saves both on a miss, and `regenerate.yml` saves `node_modules` too, for packages-only PRs that skip `checks`. `build` needs only the packages' JavaScript (`ci:build:website-lib`).
  - `VERCEL_ENV` is `preview` for pull requests (builds drafts) and `production` otherwise, including the merge queue, so the queue checks the build master deploys (a link to a draft fails there instead of as a red master commit). The Rspack cache is saved only by master pushes and restored only from the same `yarn.lock` hash: a cache built from other dependencies breaks the build. The build fails on any `[WARNING]`/`[ERROR]` line; broken links are `warn` in `docusaurus.config.ts`, so this catches them without stopping the build.
  - Each locale is a full build, so a pull request builds English plus the locales `website/scripts/pick-locales.mjs` picks from its diff (its header and tests hold the rules). Other locale-only breaks surface in the queue, which, like master, builds every locale.
  - `check-build-base.mjs` fails when a page or stylesheet loads a static file from outside its build's baseUrl (`/next/`, `/es/`), which that build's installed offline app can't serve; a PR's English build has base `/`, so locale builds and the queue's `/next/` build check English docs. `check-lazy-chunks.mjs` keeps the Store inspector in the lazy `PreviewWithScope` chunk. The upload needs `include-hidden-files`: `static/.circleci` ships with the site.
- Archived docs (`<version>.dataclient.io`, `website/scripts/snapshot/README.md`): `docs-snapshot.yml`'s build job runs an old release's install and build, which is third-party code, so it gets no write token or dependency cache (`persist-credentials: false`). Only its `publish` job writes, and it installs nothing. Assets are named by a sha256 prefix, so a rebuild adds an asset instead of replacing the listed one, and `fetch.sh` refuses one that doesn't match `versionsArchived.json`. Each archive serves at its own host through one `vercel.json` rewrite to `archives/<version>/`; Vercel runs rewrites after its file check, where the root's pages would answer, so `docs deploy` moves that route ahead of the check in `.vercel/output/config.json` and fails if it can't find it; Vercel also answers every miss with the root's `404.html`, so `docs deploy` adds an error-phase route per `404.html` in the build (each archive host's and its locales' first, then `/next/` and locales, longest prefix first) serving that build's own 404 page, and fails if the config has no error phase; the root's own `/archives/<version>/` paths redirect to that host. Production deploys add each listed host to the Vercel project (`vercel domains add`). Every version's site reads the root's `site-versions.json` (written by master's build) for its version menu, and the menu checks (`HEAD`) whether the page exists in each version, linking that version's section home (like `/docs`) when it 404s, so `vercel.json` lets other origins read every path but `/api`.
- Current and next (`scripts/maintenance/README.md`): while `website/versionCurrent.json` exists (`cut.sh` adds it; the archive's listing PR deletes it), master's push, dispatch and queue builds go to `/next/` (they build the blog to check its links, but `/next/blog` loads the root's), and the `current` job builds the `<version>.x` branch at the root, blog included (no write token, as above). Blog URLs never change: `/blog` is always the root's. Only the branch's `maintenance.yml` dispatch rebuilds the root after a branch push. `docs deploy` needs both, fails unless both found the file, and fails unless `vercel.json` has exactly one `/next{/:path...}` redirect to drop. With no next build, `website` puts `website/scripts/snapshot/retiredSw.js` at `next/sw.js` and `next/<locale>/sw.js`, which that redirect leaves out, so a worker installed from `/next` unregisters instead of serving its frozen precache. The site reads both only through `website/siteVersions.ts`, which checks their invariants. `scripts/maintenance/version-line.sh` defines release lines and which one a release replaces (`maintenance-scripts.yml` runs `version-line.test.sh` through `paths-gate.yml`, so it can be required); `siteVersions.ts` mirrors its `is_line`. `website/scripts/snapshot/docusaurus.snapshot.config.ts` and `withoutBlog.ts` also run against old releases' configs, so read only fields every release has. A maintenance branch's workflows, `publish.sh`, `version-line.sh` and `create-github-releases.mjs` are copies made by `cut.sh`: changing them here doesn't reach an existing branch.
- `website-port.yml` is the reverse of the branch's `maintenance.yml` (`scripts/maintenance/README.md#website-fixes`): while `versionCurrent.json` exists, each master push opens an auto-merging PR to its `ref` branch picking the pushed commits that change only `website/src` or `website/static`, but not the editor types or the demos' code. Its push `paths` must match the step's `site` pattern. It uses the port app's token (`PORT_APP_CLIENT_ID` variable, `PORT_APP_PRIVATE_KEY` secret) when set, so the PR runs CI and its merge starts the branch's `maintenance.yml`; with the workflow token instead, neither starts workflows, so it dispatches `site-preview.yml` itself when the PR merged at once (hence `actions: write`).
- `site-preview.yml` runs `blog-publish.mjs --check`: posts published in the PR must be dated within 3 days by filename. The merge queue rechecks, since a PR can wait there past the limit.
- Docs deploys (dataclient.io and previews) come only from the `docs deploy` job in `docs-deploy.yml`, which only `site-preview.yml` calls: `vercel pull`, then `vercel build`, then `vercel deploy --prebuilt --archive=tgz` (`--target production` on master). PR previews then run `vercel alias set --scope "$VERCEL_ORG_ID"` (alias ignores `VERCEL_ORG_ID` and otherwise looks in the token's personal account) to point `docs-site-pr-<number>-data-client.vercel.app` at the new deploy, so a PR keeps one link across pushes (on failure it warns and links the deploy itself). Closed PRs' aliases are left in place. `website/vercel.json` sets `installCommand: ""` and `buildCommand: "test -f build/index.html"`, so `vercel build` only packages the downloaded artifact into `.vercel/output` (routes from `cleanUrls` and `redirects`) and bundles the functions in `website/api/` (e.g. the homepage SSE demo's `ticker-stream`), and the job checks out only `website/package.json`, `website/vercel.json`, `website/versionsArchived.json`, `website/api/` and `website/scripts/vercel-ignore.sh`. The bare `website/api/package.json` keeps `vercel build`'s `npm install` for the functions (run with `NPM_CONFIG_IGNORE_SCRIPTS`) off the website's `workspace:` dependencies. The job runs no repo script but `vercel-ignore.sh --superseded` (production only); `vercel build` otherwise only runs `vercel.json`'s guard command, so the token is as exposed as same-repo push access (that file can change the command). Only that job reads the `VERCEL_*` secrets; `build` sees only whether they're set (repository secrets, not environment secrets). The CLI is pinned in `VERCEL_CLI_VERSION`, which Renovate's `customManagers` updates (its file pattern names `docs-deploy.yml`).
- `build` decides the deploy after the build, with `website/scripts/vercel-ignore.sh`. Same-repo PRs deploy a preview when their net site diff versus master is non-empty. Fork PRs, `renovate/*`, the merge queue and missing secrets never deploy (a notice, checks stay green). master compares `github.event.before` with the pushed commit. Right before a production deploy, `docs deploy` runs `vercel-ignore.sh --superseded`, which skips when master already moved to a newer commit that changed `SITE_PATHS` (that queued run deploys) and fails the job when it can't fetch master, so a re-run of either job can't roll production back. `workflow_dispatch` on master deploys production unless superseded; use it, or re-run `docs deploy`, after a failed deploy. The PR's `docs-site-pr-<number>` alias appears on the PR as a GitHub deployment (`docs-preview`), the job summary and a sticky comment (`header: docs-preview`), which also links the push's own deploy.
- `build` and `checks` check out full history (`fetch-depth: 0`; the merge queue uses 1) for "Last updated" dates and `translate.mjs check`, blobless (`filter: blob:none`, except in the queue): the dates need only commits and trees, and old file versions download when read. `translate.mjs check` fetches the old English its stale translations need in one `git fetch` rather than one round trip each. Never `git fetch --depth` or `--deepen` in those jobs: on a full clone it makes the repo shallow and dates every page at the cut-off. `vercel-ignore.sh` fetches master without them, and the blog-date step fetches the base only when it's missing.
- `site-preview.yml` push `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. A workflow's push `paths` also list the local workflows and actions it uses (`docs-deploy.yml`, `.github/actions/website-install/**`).
- `benchmark-react.yml` caches Playwright browsers keyed on the resolved `playwright` version from `examples/benchmark-react`; bumping playwright invalidates the cache automatically.
- Benchmark workflows (`benchmark.yml`, `benchmark-react.yml`) tune the host (CPU governor, swapoff) and pin CPUs with `taskset` — they must run directly on the runner, not in a `container:`.
- Never cancel a run on master: a cancelled check marks the commit red, which hurts npm search scoring. PR runs cancel superseded ones (`group: <name>-${{ github.head_ref || github.run_id }}`, `cancel-in-progress: true`); push runs get a unique group. Runs that must not overlap (`release.yml`, `beta-release.yml`, `website-port.yml`) use a shared group with `queue: max`, which keeps up to 100 pending runs in order instead of cancelling all but one (GitHub rejects it alongside `cancel-in-progress: true`; actionlint 1.7.12 doesn't know the key yet). Workflows whose shared push group also serves PRs (`benchmark*.yml`) pick the mode per event: `cancel-in-progress: ${{ github.event_name == 'pull_request' }}` with `queue: ${{ github.event_name == 'push' && 'max' || 'single' }}`. `site-preview.yml` cancels superseded PR runs per head branch, and queues master pushes and dispatches in one shared group (`site-deploy-production`, `queue: max`), so production deploys in push order.
- Report-style workflows (`benchmark*.yml`, `bundle_size.yml`, `codeql-analysis.yml`) skip draft PRs with a job-level `if: ${{ !github.event.pull_request.draft }}` and list `ready_for_review` in `pull_request.types`, so they run once a PR is marked ready. Correctness checks (`regenerate`, `website`) still run on drafts.
- `paths` leave out what a workflow never reads, so test- or docs-only edits under `packages/` don't fan out: `__tests__/`, `typescript-tests/`, `src-*-types/` (legacy types) and `*.md`. `bundle_size.yml` also skips packages `examples/test-bundlesize` doesn't bundle (graphql, test, vue).
- CodeQL triggers only on shipped source (`packages/*/src/**`, `packages/*/node.mjs`). `bundle_size.yml` is PR-only: on push the action measures but has nowhere to report.
- Bundle Size and the benchmarks list `yarn.lock` in `paths` but gate their main job on the reusable `dependency-gate.yml` (`scripts/ci-deps-relevant.mjs`). It runs the job when a non-manifest file in the workflow's paths changed, a manifest of the measured workspaces (or a workspace package they ship with) changed, or the yarn.lock resolutions reachable from their dependencies or the babel/browserslist/core-js build tooling changed. Bumps of test, lint, React Native or website tooling skip it; anything it can't classify runs. The gate reads the caller's `on.<event>.paths` itself (via `github.workflow_ref` and `yq`), so there's one list. Root `devDependencies` aren't walked: add a new root build dependency to `BUILD_TOOLS`.
- master merges through a squash merge queue, so PRs don't need master merged in to stay mergeable. Queue entries run on `gh-readonly-queue/master/*`: CircleCI builds them like any branch, Actions runs only workflows with a `merge_group` trigger, and `site-preview.yml` builds them without deploying.
- Required Actions checks (`agent-rules`, `regenerate`, `website`; branch protection matches the job `name`) must report on every PR and queue entry, but `merge_group` ignores `paths` and a required check that never runs blocks the merge. So only their `push` trigger has `paths`; on PRs and the queue, `paths-gate.yml` skips (passes) the job unless a file in the caller's `on.push.paths` changed. The gate's checkout is sparse (`.github/workflows`) and blobless, so it can read only workflow files. Keep `paths-gate.yml` in each caller's push `paths`. A new required check needs `merge_group`, the gate and a stable job `name`.

## Vercel docs site (`website/vercel.json`, `website/scripts/vercel-ignore.sh`)

- Vercel's Git integration never deploys: `git.deploymentEnabled: false` creates no deployment for any push (verified Jan 2024–Mar 2026: no Vercel statuses on PRs while Actions prebuilt deploys kept working). Vercel reads it from the pushed commit, so branches that predate it still build on Vercel until they merge master. Don't use an `ignoreCommand` instead: an ignored build still starts a Vercel build machine and is billed and counted as a deployment.
- Keep `installCommand: ""` and the `buildCommand` guard: they make `vercel build` package the Actions output, and make any build started on Vercel fail in seconds instead of running the full build.
- `vercel-ignore.sh`: exit 0 skips the deploy, anything else deploys, so fail open. Never diff only `HEAD^`: a master merge or multi-commit push makes it wrong. Run `vercel-ignore.test.sh` after changes.
- Never use a ref equal to `HEAD` as the master base (a checkout of master carries one, and it makes every diff empty).