Skip to content
Merged
6 changes: 6 additions & 0 deletions .cursor/rules/ci-config.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@ alwaysApply: false
## GitHub Actions (`.github/workflows/`)

- Workflows install only needed workspaces via `./scripts/ci-install.sh [extra-workspace ...]`.
- `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`.
- `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:`.

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

- Vercel's ignore step: exit 0 skips, anything else builds, so fail open. Never diff only `HEAD^`: a master merge or multi-commit push makes it wrong. Run `vercel-ignore.test.sh` after changes.
- `git.deploymentEnabled: false` doesn't stick (dashboard overrides it); skipped pushes still show as canceled deployments.
7 changes: 6 additions & 1 deletion .github/workflows/site-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ on:
pull_request:
branches:
- master
# Published site only. Keep in sync with website/scripts/vercel-ignore.sh.
paths:
- 'website/**'
- 'docs/**'
- 'docs/core/**'
- 'docs/rest/**'
- 'docs/graphql/**'
- '.github/workflows/site-preview.yml'

concurrency:
Expand All @@ -23,6 +26,8 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Test Vercel ignore decision
run: bash website/scripts/vercel-ignore.test.sh
- uses: actions/setup-node@v6
with:
node-version: '26'
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/site-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ on:
branches:
- master
- rest-hooks-site
# Published site only. Keep in sync with website/scripts/vercel-ignore.sh.
paths:
- 'website/**'
- 'docs/**'
- 'docs/core/**'
- 'docs/rest/**'
- 'docs/graphql/**'
- '.github/workflows/site-release.yml'

concurrency:
Expand Down
100 changes: 100 additions & 0 deletions website/scripts/vercel-ignore.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Decide whether Vercel should build the docs site.
#
# Exit 0 skips the build. Exit 1 builds. Vercel treats every non-zero status
# as "build", so this script exits 0 only when it can see that the published
# site is unchanged. If git history is missing, it builds (fail open).
#
# Preview branches must not use `git diff HEAD^ HEAD`. Merging master into a
# pull request makes that diff the incoming master tree, so a site commit
# already on master starts a full preview build of an unrelated PR.

set -u

# The published site: the Docusaurus app plus the doc trees it compiles.
# Keep in sync with the `paths` of site-preview.yml and site-release.yml.
SITE_PATHS=(
website docs/core docs/rest docs/graphql
':(exclude)website/CHANGELOG.md'
':(exclude,glob)website/**/__tests__/**'
':(exclude,glob)website/**/*.test.*'
)

build() {
echo "vercel-ignore: build — $*"
exit 1
}

skip() {
echo "vercel-ignore: skip — $*"
exit 0
}

[[ "${VERCEL_GIT_COMMIT_REF:-}" == gh-pages* ]] && skip "gh-pages branch"

cd "$(git rev-parse --show-toplevel)" || build "cannot find repo root"

# Builds if site paths changed between $1 and $2 (or the diff fails); else skips.
decide() {
local files
files="$(git diff --name-only --no-renames "$1" "$2" -- "${SITE_PATHS[@]}" 2>/dev/null)" ||
build "could not diff $1..$2"
[ -n "$files" ] || skip "$3"
build "$3: ${files//$'\n'/, }"
}

has_rev() {
git rev-parse --verify -q "$1" >/dev/null
}

is_ancestor() {
[ -n "$1" ] && git merge-base --is-ancestor "$1" "$2" 2>/dev/null
}

# Prints master's sha. Vercel clones shallow, so fetch it if missing; the
# timeout keeps a hung network call from holding the build machine.
upstream() {
has_rev origin/master ||
timeout 15 git fetch -q --no-tags --depth=80 origin master:refs/remotes/origin/master 2>/dev/null
git rev-parse --verify -q origin/master || git rev-parse --verify -q master
}

# Vercel clones about 10 commits deep. Fetch more of this branch and master
# when a comparison base is out of reach.
deepen() {
[ -n "${VERCEL_GIT_COMMIT_REF:-}" ] &&
timeout 30 git fetch -q --no-tags --deepen=300 origin "$VERCEL_GIT_COMMIT_REF" \
'+refs/heads/master:refs/remotes/origin/master' 2>/dev/null
}

merge_base() {
master="$(upstream)" && git merge-base HEAD "$master" 2>/dev/null
}

prev="${VERCEL_GIT_PREVIOUS_SHA:-}"

# A push can carry several commits (rebase merges), so compare against the
# last deploy.
if [[ "${VERCEL_GIT_COMMIT_REF:-}" =~ ^(master|rest-hooks-site)$ || "${VERCEL_ENV:-}" == production ]]; then
[ -n "$prev" ] && ! has_rev "$prev^{commit}" && deepen
is_ancestor "$prev" HEAD && decide "$prev" HEAD "production changes since ${prev:0:12}"
build "no previous production deploy to compare"
fi

# Previews compare the branch's changes, not commits merged in from upstream.
# When the tip merges master, diff against the merged master commit: that
# counts the branch's site files and any conflict resolutions, not master's.
# Merges of other branches fall through and count in full.
if has_rev 'HEAD^2' && master="$(upstream)" && is_ancestor 'HEAD^2' "$master"; then
decide 'HEAD^2' HEAD "preview changes vs master (merge)"
fi

is_ancestor "$prev" HEAD && decide "$prev" HEAD "preview changes since ${prev:0:12}"

if base="$(merge_base)" || { deepen && base="$(merge_base)"; }; then
decide "$base" HEAD "preview changes vs master"
fi
Comment thread
cursor[bot] marked this conversation as resolved.

# Without a base, the tip commit alone can't prove earlier commits left the
# site unchanged.
build "no base to compare"
183 changes: 183 additions & 0 deletions website/scripts/vercel-ignore.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Exercises website/scripts/vercel-ignore.sh against a throwaway repo.
set -euo pipefail

root="$(cd "$(dirname "$0")/../.." && pwd)"
script="$root/website/scripts/vercel-ignore.sh"
repo="$(mktemp -d)"
trap 'rm -rf "$repo"' EXIT

git -C "$repo" init -b master >/dev/null
git -C "$repo" config user.email "vercel-ignore-test@example.com"
git -C "$repo" config user.name "vercel-ignore-test"
git -C "$repo" config commit.gpgsign false
git -C "$repo" config core.fsmonitor false
git -C "$repo" config core.untrackedcache false

commit() {
local msg="$1"
shift
local path
for path in "$@"; do
mkdir -p "$repo/$(dirname "$path")"
printf '%s\n' "$msg" >>"$repo/$path"
git -C "$repo" add -- "$path"
done
git -C "$repo" commit -m "$msg" >/dev/null
}

# expect <skip|build> <name> <ref> [previous-sha] [vercel-env]
expect() {
local want="$1" name="$2" ref="$3" out rc code=1
[ "$want" = skip ] && code=0
set +e
out="$(
cd "$repo" &&
VERCEL_GIT_COMMIT_REF="$ref" VERCEL_GIT_PREVIOUS_SHA="${4-}" VERCEL_ENV="${5-}" \
bash "$script" 2>&1
)"
rc=$?
set -e
if [ "$rc" -ne "$code" ]; then
printf 'FAIL %s: expected %s (%s), got %s\n%s\n' "$name" "$want" "$code" "$rc" "$out" >&2
exit 1
fi
printf 'ok %s\n' "$name"
}

# Previous deploy for a single-commit push.
parent() { git -C "$repo" rev-parse HEAD^; }

commit "init" README.md

# --- production (master): one squash commit ---
commit "pkg" packages/core/src/index.ts
expect skip "master package-only" master "$(parent)"

commit "docs page" docs/core/api/Controller.md
expect build "master docs/core" master "$(parent)"

commit "roadmap" docs/ROADMAP.md
expect skip "master docs/ROADMAP.md" master "$(parent)"

commit "prettier" docs/.prettierrc
expect skip "master docs/.prettierrc" master "$(parent)"

commit "blog" website/blog/2026-10-04-note.md
expect build "master website blog" master "$(parent)"

commit "changelog" website/CHANGELOG.md
expect skip "master website changelog" master "$(parent)"

commit "unit test" website/src/components/Playground/__tests__/transformCode.test.ts website/src/components/Playground/__tests__/fixture.json
expect skip "master website unit test" master "$(parent)"

commit "colocated test" website/src/components/Playground/transformCode.test.ts website/scripts/vercel-ignore.test.sh
expect skip "master colocated website tests" master "$(parent)"

commit "ci" .circleci/config.yml .github/workflows/benchmark.yml
expect skip "master CI-only" master "$(parent)"

# Last successful production deploy was before a package commit. Still skip.
pkg_sha="$(git -C "$repo" rev-parse HEAD)"
commit "another pkg" packages/rest/src/index.ts
expect skip "master package since previous deploy" master "$pkg_sha"

# --- preview branch, linear ---
git -C "$repo" checkout -b feature >/dev/null 2>&1
commit "feature pkg" packages/vue/src/index.ts .changeset/preview.md
expect skip "preview package and changeset" feature

commit "feature roadmap" docs/ROADMAP.md
expect skip "preview docs that are not published" feature

commit "feature page" docs/rest/api/Entity.md
expect build "preview docs/rest" feature

commit "graphql page" docs/graphql/api/GQLEndpoint.md
expect build "preview docs/graphql since last deploy" feature "$(git -C "$repo" rev-parse HEAD^)"

commit "feature ci follow-up" .github/workflows/site-preview.yml
# Whole branch still contains docs/rest, and there is no previous deploy,
# so the site change must still build.
expect build "preview follow-up after unpublished site change" feature

page_sha="$(git -C "$repo" rev-parse HEAD)"
commit "feature pkg follow-up" packages/core/src/other.ts
expect skip "preview package follow-up after site deploy" feature "$page_sha"

commit "playground source" website/src/components/Playground/transformCode.ts
expect build "preview playground source since last deploy" feature "$page_sha"

# Multi-commit branch with no prior deploy: the site edit is not the tip.
git -C "$repo" checkout -b stacked master >/dev/null 2>&1
commit "stacked site" website/src/pages/index.js
commit "stacked pkg" packages/endpoint/src/index.ts
expect build "preview multi-commit site change not at tip" stacked

# --- merge master into a package PR (the credit-burn case) ---
git -C "$repo" checkout master >/dev/null 2>&1
commit "master site moves" website/src/pages/index.js docs/core/concepts/overview.md
master_sha="$(git -C "$repo" rev-parse HEAD)"

# Branch from before that commit, so master is ahead when the PR merges it.
git -C "$repo" checkout -b pkg-pr "$master_sha^" >/dev/null 2>&1
commit "pr packages" packages/core/src/set.ts .circleci/config.yml
git -C "$repo" merge --no-edit "$master_sha" >/dev/null
expect skip "preview merge of master into package PR" pkg-pr

# Site PR already deployed, then merges master: rebuild so the preview
# reflects the merged result.
git -C "$repo" checkout -b site-pr "$master_sha^" >/dev/null 2>&1
commit "pr website" website/docusaurus.config.ts
deployed="$(git -C "$repo" rev-parse HEAD)"
git -C "$repo" merge --no-edit "$master_sha" >/dev/null
expect build "preview merge of master into a deployed site PR" site-pr "$deployed"

# A conflict resolution in the merge commit changes the site itself.
git -C "$repo" checkout -b conflict-pr "$master_sha^" >/dev/null 2>&1
commit "pr index" website/src/pages/index.js
conflict_deployed="$(git -C "$repo" rev-parse HEAD)"
git -C "$repo" merge --no-edit "$master_sha" >/dev/null 2>&1 || true
printf 'resolved\n' >"$repo/website/src/pages/index.js"
git -C "$repo" commit -qam "merge master" >/dev/null
expect build "preview merge with a site conflict resolution" conflict-pr "$conflict_deployed"

# Site PR that has never deployed, merged with master: still build.
git -C "$repo" checkout -B site-pr-fresh "$deployed" >/dev/null 2>&1
git -C "$repo" merge --no-edit "$master_sha" >/dev/null
expect build "preview merge of a site PR with no prior deploy" site-pr-fresh

# Merging another feature branch (not upstream) brings its site changes in.
git -C "$repo" checkout -b feat-docs "$deployed" >/dev/null 2>&1
commit "stacked docs" docs/core/api/Stacked.md
git -C "$repo" checkout -b feat "$deployed" >/dev/null 2>&1
commit "feat pkg" packages/core/src/feat.ts
git -C "$repo" merge --no-edit feat-docs >/dev/null
expect build "preview merge of a non-upstream branch with site changes" feat "$deployed"

# No merge-base with master (e.g. shallow history): the tip alone can't prove
# the site is unchanged, so build.
git -C "$repo" checkout --orphan unrelated >/dev/null 2>&1
commit "unrelated site" website/src/pages/index.js
commit "unrelated pkg" packages/core/src/index.ts
expect build "preview without merge-base builds" unrelated

# gh-pages branches never build, even if website files differ.
expect skip "gh-pages branch" gh-pages-bench

# Last production deploy is outside the clone (shallow history): the tip
# alone can't prove earlier commits in the push left the site unchanged.
git -C "$repo" checkout master >/dev/null 2>&1
commit "rebase-merged docs" docs/rest/api/Rebased.md
commit "rebase-merged pkg" packages/rest/src/rebased.ts
expect build "master with unreachable previous deploy" master 0123456789abcdef0123456789abcdef01234567

# VERCEL_ENV=production uses this branch's history, not the diff against master.
# A site commit still builds; a later package-only commit does not.
commit "prod site" website/src/pages/index.js
expect build "production env site tip" other-branch "$(parent)" production
commit "prod pkg" packages/normalizr/src/index.ts
expect skip "production env package tip" other-branch "$(parent)" production

echo "all vercel-ignore cases passed"
2 changes: 1 addition & 1 deletion website/vercel.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"cleanUrls": true,
"ignoreCommand": "case \"$VERCEL_GIT_COMMIT_REF\" in gh-pages*) exit 0;; esac; if [ \"$VERCEL_GIT_COMMIT_REF\" = \"master\" ]; then git diff HEAD^ HEAD --quiet -- . ../docs/; else git diff HEAD^ HEAD --quiet -- .; fi"
"ignoreCommand": "bash scripts/vercel-ignore.sh"
}
Loading