diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index a67967962284..490a6482dd4e 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -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. diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index a45fb423020d..074af3caeb42 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -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: @@ -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' diff --git a/.github/workflows/site-release.yml b/.github/workflows/site-release.yml index 9192847e87d2..651a5dd5c242 100644 --- a/.github/workflows/site-release.yml +++ b/.github/workflows/site-release.yml @@ -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: diff --git a/website/scripts/vercel-ignore.sh b/website/scripts/vercel-ignore.sh new file mode 100755 index 000000000000..09a0449af9d5 --- /dev/null +++ b/website/scripts/vercel-ignore.sh @@ -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 + +# Without a base, the tip commit alone can't prove earlier commits left the +# site unchanged. +build "no base to compare" diff --git a/website/scripts/vercel-ignore.test.sh b/website/scripts/vercel-ignore.test.sh new file mode 100755 index 000000000000..253880b66554 --- /dev/null +++ b/website/scripts/vercel-ignore.test.sh @@ -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 [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" diff --git a/website/vercel.json b/website/vercel.json index ee2a9de83d7e..b79686e64824 100644 --- a/website/vercel.json +++ b/website/vercel.json @@ -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" }