From 0b24a8a472109b928399bc311810f9417c03bf08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 19:48:06 +0000 Subject: [PATCH 1/2] ci(website): Skip docs-site previews that die in yarn prepare Renovate pushes were starting a docs-site preview whenever website/package.json changed. The install command then exited 2 because yarn prepare runs tsc, and react-native 0.87 removed InteractionManager while packages/react still imports it. Skip renovate/* previews whose only site changes are non-major website dependency bumps. Also skip previews that would die in that prepare failure, unless site or docs source changed. Production deploys are unchanged. Co-authored-by: Nathaniel Tucker --- .cursor/rules/ci-config.mdc | 3 + .github/workflows/site-preview.yml | 33 +++ website/scripts/vercel-ignore-decide.js | 308 ++++++++++++++++++++ website/scripts/vercel-ignore.sh | 82 ++++++ website/scripts/vercel-ignore.test.sh | 355 ++++++++++++++++++++++++ 5 files changed, 781 insertions(+) create mode 100644 website/scripts/vercel-ignore-decide.js diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 10f951be8a12..1404c78f9dab 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -32,3 +32,6 @@ alwaysApply: false - 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. +- Previews on `renovate/*` skip when the site diff is only non-major dependency bumps under `website/`. A major bump of a dependency declared in a website `package.json` still builds. Real site or docs changes still build. Production (`master`, `rest-hooks-site`, or `VERCEL_ENV=production`) is unchanged. +- The docs-site install command dies with status 2 in `yarn prepare` (`tsc --build`), not in `yarn install`, when `react-native` >= 0.87 is installed and compiled `packages/` sources still import `InteractionManager` (removed from RN's public types in 0.87; `TS2305`). Previews with no site or docs source change skip in that case. Production and real site/docs changes still build. +- `site-preview.yml` runs this script and skips its Vercel build and website typecheck when the script skips. `vercel-ignore-decide.js` classifies dependency bumps and the prepare failure. diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 074af3caeb42..b44f45d43756 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -19,7 +19,34 @@ concurrency: cancel-in-progress: true jobs: + # Same decision the Vercel project runs via ignoreCommand. Exit 0 skips the + # preview (Renovate non-major website bumps, or yarn prepare will exit 2). + preview: + name: Decide whether to build + runs-on: ubuntu-latest + outputs: + skip: ${{ steps.decision.outputs.skip }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 2 + - name: Fetch pull request base + run: git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} || true + - name: Run Vercel ignore decision + id: decision + env: + VERCEL_GIT_COMMIT_REF: ${{ github.head_ref }} + VERCEL_GIT_PREVIOUS_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if bash website/scripts/vercel-ignore.sh; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + typecheck: + needs: preview runs-on: ubuntu-latest steps: - name: Checkout @@ -29,17 +56,23 @@ jobs: - name: Test Vercel ignore decision run: bash website/scripts/vercel-ignore.test.sh - uses: actions/setup-node@v6 + if: needs.preview.outputs.skip != 'true' with: node-version: '26' cache: 'yarn' - name: Install packages + if: needs.preview.outputs.skip != 'true' run: ./scripts/ci-install.sh website - name: Build package types + if: needs.preview.outputs.skip != 'true' run: yarn ci:build:types - name: Typecheck website + if: needs.preview.outputs.skip != 'true' run: yarn workspace rdc-website typecheck deploy: + needs: preview + if: needs.preview.outputs.skip != 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/website/scripts/vercel-ignore-decide.js b/website/scripts/vercel-ignore-decide.js new file mode 100644 index 000000000000..b26ee8604c1d --- /dev/null +++ b/website/scripts/vercel-ignore-decide.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node +/** + * Helpers for website/scripts/vercel-ignore.sh. + * + * node vercel-ignore-decide.js classify + * Reads OLD_PKG and NEW_PKG. Exits 0 when the package.json edit is only + * non-major dependency bumps. Exits 1 for a major bump or anything else + * (added/removed dependencies, scripts, unparsable ranges). + * + * node vercel-ignore-decide.js prepare-failure + * Exits 0 when `yarn prepare` (`tsc --build`) is expected to exit 2. + * react-native 0.87 dropped InteractionManager from its public types + * (still exported in 0.86.2). packages/react still imports it, so tsc + * reports TS2305 and the Vercel install command + * `cd .. && yarn install && yarn prepare && yarn ci:build && ...` + * dies with status 2 after yarn install has already succeeded. + * Exits 1 when that failure is not expected. + * + * node vercel-ignore-decide.js self-test + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEP_FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + 'resolutions', + 'overrides', +]; + +// First release whose public types omit InteractionManager. +const INTERACTION_MANAGER_REMOVED = [0, 87, 0]; + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') { + return Object.keys(value) + .sort() + .map(key => [key, stable(value[key])]); + } + return value; +} + +function rest(pkg) { + const copy = { ...pkg }; + for (const field of DEP_FIELDS) delete copy[field]; + return copy; +} + +function flatten(value, prefix, out) { + if (typeof value === 'string') { + out[prefix] = value; + return; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}\0${key}` : key, out); + } + return; + } + if (prefix) out[prefix] = value; +} + +function flattenFields(pkg) { + const out = {}; + for (const field of DEP_FIELDS) { + if (pkg[field] && typeof pkg[field] === 'object') flatten(pkg[field], field, out); + } + return out; +} + +// Version majors mentioned by a specifier, ignoring range punctuation. +// `npm:pkg@version` uses the aliased version. workspace/file/git specs are +// literals. Returns null when the specifier is not a version we can compare. +function classifySpec(spec) { + if (typeof spec !== 'string') return null; + let body = spec.trim(); + const alias = body.match(/^npm:(?:@[^\s@/]+\/[^\s@]+|[^\s@/]+)@(.+)$/); + if (alias) body = alias[1].trim(); + if (/^(workspace:|catalog:|file:|link:|portal:|github:|git\+|git:|https?:)/.test(body)) { + return { kind: 'literal', value: spec.trim() }; + } + const majors = []; + // Build metadata (`+2020`) stays part of the same version so it is not a + // second major. + const re = /(?:^|[^\d.])v?(\d+)(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/g; + let match; + while ((match = re.exec(body))) majors.push(Number(match[1])); + if (majors.length === 0) return null; + return { kind: 'semver', majors }; +} + +// 'nonmajor' | 'major' | 'other' +function compareSpecs(before, after) { + if (before === after) return 'nonmajor'; + const left = classifySpec(before); + const right = classifySpec(after); + if (!left || !right || left.kind !== right.kind) return 'other'; + if (left.kind === 'literal') return left.value === right.value ? 'nonmajor' : 'other'; + if (left.majors.length !== right.majors.length) return 'major'; + for (let i = 0; i < left.majors.length; i++) { + if (left.majors[i] !== right.majors[i]) return 'major'; + } + return 'nonmajor'; +} + +function classify(oldText, newText) { + let oldPkg; + let newPkg; + try { + oldPkg = JSON.parse(oldText); + newPkg = JSON.parse(newText); + } catch { + return 'other'; + } + if (!oldPkg || !newPkg || typeof oldPkg !== 'object' || typeof newPkg !== 'object') { + return 'other'; + } + if (Array.isArray(oldPkg) || Array.isArray(newPkg)) return 'other'; + if (JSON.stringify(stable(rest(oldPkg))) !== JSON.stringify(stable(rest(newPkg)))) { + return 'other'; + } + const before = flattenFields(oldPkg); + const after = flattenFields(newPkg); + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + let other = false; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(before, key)) return 'other'; + if (!Object.prototype.hasOwnProperty.call(after, key)) return 'other'; + if (typeof before[key] !== 'string' || typeof after[key] !== 'string') return 'other'; + const change = compareSpecs(before[key], after[key]); + if (change === 'major') return 'major'; + if (change === 'other') other = true; + } + return other ? 'other' : 'nonmajor'; +} + +function versionAtLeast(spec, target) { + const match = String(spec).match(/(\d+)\.(\d+)(?:\.(\d+))?/); + if (!match) return false; + const ver = [Number(match[1]), Number(match[2]), match[3] === undefined ? 0 : Number(match[3])]; + for (let i = 0; i < 3; i++) { + if (ver[i] !== target[i]) return ver[i] > target[i]; + } + return true; +} + +function declaredReactNative(pkg) { + const resolutions = pkg.resolutions || {}; + const overrides = pkg.overrides || {}; + const pinned = resolutions['react-native'] || resolutions['**/react-native'] || overrides['react-native']; + if (typeof pinned === 'string') return pinned; + const deps = pkg.dependencies || {}; + const dev = pkg.devDependencies || {}; + if (typeof deps['react-native'] === 'string') return deps['react-native']; + if (typeof dev['react-native'] === 'string') return dev['react-native']; + return ''; +} + +function importsInteractionManager(src) { + const re = /(?:import|export)\s+(?:type\s+)?([\s\S]{0,500}?)\s+from\s+['"]react-native['"]/g; + let match; + while ((match = re.exec(src))) { + if (/\bInteractionManager\b/.test(match[1])) return true; + } + return false; +} + +function compiledSourceImportsInteractionManager(root) { + const packages = path.join(root, 'packages'); + const stack = [packages]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if ( + entry.name === 'node_modules' || + entry.name === 'lib' || + entry.name === 'dist' || + entry.name === 'legacy' || + entry.name === '__tests__' + ) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!/\.(?:[cm]?ts|tsx)$/.test(entry.name) || /\.(?:test|spec)\.[^.]+$/.test(entry.name)) { + continue; + } + let src; + try { + src = fs.readFileSync(full, 'utf8'); + } catch { + continue; + } + if (src.includes('InteractionManager') && importsInteractionManager(src)) return true; + } + } + return false; +} + +function prepareWillFail(root) { + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + } catch { + return false; + } + if (!pkg || typeof pkg !== 'object') return false; + if (!versionAtLeast(declaredReactNative(pkg), INTERACTION_MANAGER_REMOVED)) return false; + return compiledSourceImportsInteractionManager(root); +} + +function selfTest() { + const os = require('os'); + const assert = (cond, msg) => { + if (!cond) { + console.error(`self-test failed: ${msg}`); + process.exit(1); + } + }; + const withDeps = (deps, extra = {}) => JSON.stringify({ ...extra, ...deps }); + + assert(classify(withDeps({ dependencies: { a: '^1.2.3' } }), withDeps({ dependencies: { a: '^1.4.0' } })) === 'nonmajor', 'minor'); + assert(classify(withDeps({ dependencies: { a: '1.2.3' } }), withDeps({ dependencies: { a: '1.2.4' } })) === 'nonmajor', 'patch'); + assert(classify(withDeps({ dependencies: { a: '^1.2.3' } }), withDeps({ dependencies: { a: '^2.0.0' } })) === 'major', 'major'); + assert(classify(withDeps({ dependencies: { m: '^0.56.0' } }), withDeps({ dependencies: { m: '^0.57.0' } })) === 'nonmajor', '0.x minor'); + assert(classify(withDeps({ dependencies: { m: '^0.56.0' } }), withDeps({ dependencies: { m: '^1.0.0' } })) === 'major', '0.x to 1'); + assert(classify(withDeps({ devDependencies: { '@types/react': '19.2.17' } }), withDeps({ devDependencies: { '@types/react': '19.3.0' } })) === 'nonmajor', 'types minor'); + assert(classify(withDeps({ devDependencies: { '@types/react': '19.2.17' } }), withDeps({ devDependencies: { '@types/react': '20.0.0' } })) === 'major', 'types major'); + assert(classify(withDeps({ dependencies: { t: 'npm:typescript@7.0.2' } }), withDeps({ dependencies: { t: 'npm:typescript@7.0.3' } })) === 'nonmajor', 'alias patch'); + assert(classify(withDeps({ dependencies: { t: 'npm:@typescript/typescript6@6.0.2' } }), withDeps({ dependencies: { t: 'npm:@typescript/typescript6@7.0.0' } })) === 'major', 'scoped alias major'); + assert(classify(withDeps({ dependencies: { a: '^4.8.0-rc.0' } }), withDeps({ dependencies: { a: '^4.9.0' } })) === 'nonmajor', 'prerelease minor'); + assert(classify(withDeps({ dependencies: { a: '^4.8.0-rc.0' } }), withDeps({ dependencies: { a: '^5.0.0-rc.1' } })) === 'major', 'prerelease major'); + assert(classify(withDeps({ dependencies: { a: '1.2.3+2020' } }), withDeps({ dependencies: { a: '1.2.4+2021' } })) === 'nonmajor', 'build metadata'); + assert(classify(withDeps({ dependencies: { w: 'workspace:*', a: '1.2.3' } }), withDeps({ dependencies: { w: 'workspace:*', a: '1.2.4' } })) === 'nonmajor', 'workspace untouched'); + assert(classify(withDeps({ dependencies: { a: '1.0.0' } }), withDeps({ dependencies: { a: '1.0.0', b: '1.0.0' } })) === 'other', 'added dep'); + assert(classify(withDeps({ dependencies: { a: '1.0.0', b: '1.0.0' } }), withDeps({ dependencies: { a: '1.0.0' } })) === 'other', 'removed dep'); + assert( + classify(withDeps({ dependencies: { a: '1.0.0' } }, { scripts: { build: 'a' } }), withDeps({ dependencies: { a: '1.0.1' } }, { scripts: { build: 'b' } })) === 'other', + 'script change', + ); + assert(classify(withDeps({ resolutions: { foo: '7.1.2' } }), withDeps({ resolutions: { foo: '8.0.0' } })) === 'major', 'resolution major'); + assert(classify(withDeps({ overrides: { foo: { bar: '1.2.3' } } }), withDeps({ overrides: { foo: { bar: '1.2.4' } } })) === 'nonmajor', 'nested override patch'); + assert(classify(withDeps({ overrides: { foo: { bar: '1.2.3' } } }), withDeps({ overrides: { foo: { bar: '2.0.0' } } })) === 'major', 'nested override major'); + assert(classify('{', '{') === 'other', 'invalid json'); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vercel-ignore-')); + const hook = path.join(root, 'packages/react/src/hooks'); + fs.mkdirSync(hook, { recursive: true }); + const source = path.join(hook, 'useFetch.native.ts'); + const writePkg = version => { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { 'react-native': version } })); + }; + const importing = "import { InteractionManager } from 'react-native';\n"; + try { + writePkg('0.86.2'); + fs.writeFileSync(source, importing); + assert(prepareWillFail(root) === false, '0.86 still exports InteractionManager'); + writePkg('0.87.0'); + assert(prepareWillFail(root) === true, '0.87.0 removed the export'); + writePkg('0.87.1'); + assert(prepareWillFail(root) === true, '0.87.1 removed the export'); + fs.writeFileSync(source, "import {\n InteractionManager,\n} from 'react-native';\n"); + assert(prepareWillFail(root) === true, 'multiline import'); + fs.writeFileSync(source, "export const x = 1;\n"); + assert(prepareWillFail(root) === false, 'import removed'); + fs.mkdirSync(path.join(root, 'packages/react/src/__tests__'), { recursive: true }); + fs.writeFileSync(path.join(root, 'packages/react/src/__tests__/useFetch.native.tsx'), importing); + assert(prepareWillFail(root) === false, 'test import is not compiled by tsc --build'); + writePkg('^0.86.2'); + fs.writeFileSync(source, importing); + assert(prepareWillFail(root) === false, 'range whose minimum still has the export'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function main() { + const cmd = process.argv[2]; + if (cmd === 'self-test') { + selfTest(); + return; + } + if (cmd === 'classify') { + const result = classify(process.env.OLD_PKG || '', process.env.NEW_PKG || ''); + process.exit(result === 'nonmajor' ? 0 : 1); + } + if (cmd === 'prepare-failure') { + process.exit(prepareWillFail(process.cwd()) ? 0 : 1); + } + console.error(`usage: vercel-ignore-decide.js `); + process.exit(1); +} + +main(); diff --git a/website/scripts/vercel-ignore.sh b/website/scripts/vercel-ignore.sh index 09a0449af9d5..7717e8d0924b 100755 --- a/website/scripts/vercel-ignore.sh +++ b/website/scripts/vercel-ignore.sh @@ -8,9 +8,27 @@ # 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. +# +# Renovate previews (renovate/*) skip when every published-site change is a +# non-major dependency bump under website/. A major bump of a package declared +# in a website package.json still builds, unless yarn prepare is expected to +# fail before the site compiles (below). Real site or docs changes still +# build, including on a Renovate branch. Production (master, rest-hooks-site, +# or VERCEL_ENV=production) is never skipped by these preview rules. +# +# The docs-site install command is `cd .. && yarn install && yarn prepare && +# yarn ci:build && cd website && ...`. yarn install succeeds; yarn prepare +# runs `tsc --build` and exits 2 when react-native >= 0.87 is installed, +# because that release dropped InteractionManager and packages/react still +# imports it (TS2305). Previews with no site or docs source change skip in +# that case instead of starting a build that dies in the install command. +# A real site or docs change still builds, so a site-source failure is not +# hidden. set -u +DECIDE_JS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vercel-ignore-decide.js" + # 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=( @@ -34,12 +52,76 @@ skip() { cd "$(git rev-parse --show-toplevel)" || build "cannot find repo root" +preview_context() { + [[ "${VERCEL_ENV:-}" == production ]] && return 1 + [[ "${VERCEL_GIT_COMMIT_REF:-}" =~ ^(master|rest-hooks-site)$ ]] && return 1 + return 0 +} + +is_website_manifest() { + [[ "$1" =~ ^website/(.+/)?package\.json$ ]] && return 0 + [[ "$1" =~ ^website/(.+/)?(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$ ]] +} + +# True when a changed path is published site or docs source, not a website +# dependency manifest. +site_source_changed() { + local f + while IFS= read -r f; do + [ -n "$f" ] || continue + is_website_manifest "$f" || return 0 + done <<<"$1" + return 1 +} + +# True when every changed path is a website lockfile or a package.json whose +# only edits are non-major dependency bumps. +renovate_nonmajor_only() { + local base="$1" head="$2" f old new + while IFS= read -r f; do + [ -n "$f" ] || continue + if [[ "$f" =~ ^website/(.+/)?(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$ ]]; then + continue + fi + if [[ "$f" =~ ^website/(.+/)?package\.json$ ]]; then + old="$(git show "$base:$f" 2>/dev/null)" || return 1 + new="$(git show "$head:$f" 2>/dev/null)" || return 1 + OLD_PKG="$old" NEW_PKG="$new" node "$DECIDE_JS" classify >/dev/null || return 1 + continue + fi + return 1 + done <<<"$3" + return 0 +} + +# True when yarn prepare would exit 2 for the known InteractionManager break. +# A missing node, or any other uncertainty, returns false so the build runs. +expected_prepare_failure() { + command -v node >/dev/null 2>&1 || return 1 + node "$DECIDE_JS" prepare-failure +} + +# Skip a preview that cannot show a site change: Renovate non-major website +# dependency bumps, or an install that dies in yarn prepare before the site +# compiles. Returns without skipping when the preview should build. +maybe_skip_preview() { + preview_context || return 0 + site_source_changed "$3" && return 0 + if [[ "${VERCEL_GIT_COMMIT_REF:-}" == renovate/* ]] && renovate_nonmajor_only "$1" "$2" "$3"; then + skip "$4: renovate non-major website dependency bumps" + fi + if expected_prepare_failure; then + skip "$4: yarn prepare would exit 2 (react-native dropped InteractionManager)" + fi +} + # 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" + maybe_skip_preview "$1" "$2" "$files" "$3" build "$3: ${files//$'\n'/, }" } diff --git a/website/scripts/vercel-ignore.test.sh b/website/scripts/vercel-ignore.test.sh index 253880b66554..5f87f9e93438 100755 --- a/website/scripts/vercel-ignore.test.sh +++ b/website/scripts/vercel-ignore.test.sh @@ -4,6 +4,7 @@ set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" script="$root/website/scripts/vercel-ignore.sh" +node "$root/website/scripts/vercel-ignore-decide.js" self-test repo="$(mktemp -d)" trap 'rm -rf "$repo"' EXIT @@ -26,6 +27,14 @@ commit() { git -C "$repo" commit -m "$msg" >/dev/null } +write_commit() { + local msg="$1" path="$2" + mkdir -p "$repo/$(dirname "$path")" + cat >"$repo/$path" + git -C "$repo" add -- "$path" + git -C "$repo" commit -q -m "$msg" +} + # expect [previous-sha] [vercel-env] expect() { local want="$1" name="$2" ref="$3" out rc code=1 @@ -180,4 +189,350 @@ 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 +# --- renovate previews and the yarn prepare / InteractionManager failure --- +# react-native 0.87 removed InteractionManager. yarn prepare (tsc --build) +# then exits 2, which is the Vercel install command's status. Baseline keeps +# 0.86 so the Renovate rule and the prepare-failure rule can be told apart. +git -C "$repo" checkout master >/dev/null 2>&1 +write_commit "rn baseline" package.json <<'JSON' +{ + "devDependencies": { + "react-native": "0.86.2" + } +} +JSON +write_commit "website deps" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.56.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +write_commit "native hook" packages/react/src/hooks/useFetch.native.ts <<'TS' +import { InteractionManager } from 'react-native'; +TS +baseline="$(git -C "$repo" rev-parse HEAD)" + +git -C "$repo" checkout -b renovate/all-minor-patch "$baseline" >/dev/null 2>&1 +write_commit "pkg: minor website deps" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.3.0" + } +} +JSON +expect skip "renovate minor and patch website deps" renovate/all-minor-patch + +minor_sha="$(git -C "$repo" rev-parse HEAD)" +write_commit "docs page on renovate" docs/core/api/Controller.md <<'MD' +# Controller +MD +expect build "renovate docs after a minor bump" renovate/all-minor-patch "$minor_sha" + +docs_sha="$(git -C "$repo" rev-parse HEAD)" +write_commit "pkg: another patch" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.1", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.3.0" + } +} +JSON +expect skip "renovate patch after a site deploy" renovate/all-minor-patch "$docs_sha" + +git -C "$repo" checkout -b renovate/major "$baseline" >/dev/null 2>&1 +write_commit "pkg: major website dep" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^1.0.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +expect build "renovate major website dependency" renovate/major + +git -C "$repo" checkout -b renovate/dev-major "$baseline" >/dev/null 2>&1 +write_commit "pkg: major website devDep" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.56.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "20.0.0" + } +} +JSON +expect build "renovate major website devDependency" renovate/dev-major + +git -C "$repo" checkout -b renovate/alias "$baseline" >/dev/null 2>&1 +write_commit "pkg: major npm alias" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.56.0", + "@typescript/native": "npm:typescript@8.0.0" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +expect build "renovate major npm alias" renovate/alias + +git -C "$repo" checkout -b renovate/resolution "$baseline" >/dev/null 2>&1 +write_commit "pkg: major resolution" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.56.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + }, + "resolutions": { + "serialize-javascript": "8.0.0" + } +} +JSON +expect build "renovate major website resolution" renovate/resolution + +git -C "$repo" checkout -b renovate/added "$baseline" >/dev/null 2>&1 +write_commit "pkg: add website dep" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.56.0", + "@typescript/native": "npm:typescript@7.0.2", + "left-pad": "1.0.0" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +expect build "renovate added website dependency" renovate/added + +git -C "$repo" checkout -b renovate/scripts "$baseline" >/dev/null 2>&1 +write_commit "pkg: website script" website/package.json <<'JSON' +{ + "scripts": { + "build": "docusaurus build" + }, + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +expect build "renovate website package.json script change" renovate/scripts + +git -C "$repo" checkout -b renovate/lock "$baseline" >/dev/null 2>&1 +write_commit "pkg: website lock" website/yarn.lock <<'LOCK' +# lock 1 +LOCK +expect skip "renovate website lockfile only" renovate/lock + +# Master gained site commits, then a Renovate branch with only a minor bump +# merges master. The preview diff is the branch, not the incoming site commit. +git -C "$repo" checkout master >/dev/null 2>&1 +write_commit "master site moves again" website/src/pages/index.js <<'JS' +export default function Home() {} +JS +moved="$(git -C "$repo" rev-parse HEAD)" +git -C "$repo" checkout -b renovate/merged "$baseline" >/dev/null 2>&1 +write_commit "pkg: minor before merge" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +git -C "$repo" merge --no-edit "$moved" >/dev/null +expect skip "renovate merge of master into a minor dep branch" renovate/merged + +# Non-renovate preview of a minor website bump still builds. +git -C "$repo" checkout -b feature-deps "$baseline" >/dev/null 2>&1 +write_commit "human minor website dep" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +expect build "preview non-renovate minor website dep" feature-deps + +# react-native 0.87 plus the import: yarn prepare exits 2. Manifest-only +# previews skip. Site source, docs, and production still build. +git -C "$repo" checkout -b renovate/rn "$baseline" >/dev/null 2>&1 +cat >"$repo/package.json" <<'JSON' +{ + "devDependencies": { + "react-native": "0.87.1" + } +} +JSON +cat >"$repo/website/package.json" <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.3.0" + } +} +JSON +git -C "$repo" add -- package.json website/package.json +git -C "$repo" commit -q -m "pkg: Update all non-major dependencies" +expect skip "renovate minor with prepare failure" renovate/rn + +git -C "$repo" checkout -b renovate/rn-major "$baseline" >/dev/null 2>&1 +cat >"$repo/package.json" <<'JSON' +{ + "devDependencies": { + "react-native": "0.87.1" + } +} +JSON +cat >"$repo/website/package.json" <<'JSON' +{ + "dependencies": { + "monaco-editor": "^1.0.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +git -C "$repo" add -- package.json website/package.json +git -C "$repo" commit -q -m "pkg: major website dep on rn 0.87" +expect skip "renovate major skipped when prepare will fail" renovate/rn-major + +write_commit "docs beside broken prepare" docs/rest/api/Entity.md <<'MD' +# Entity +MD +expect build "renovate docs still build when prepare will fail" renovate/rn-major + +git -C "$repo" checkout -b feature-broken "$baseline" >/dev/null 2>&1 +cat >"$repo/package.json" <<'JSON' +{ + "devDependencies": { + "react-native": "0.87.0" + } +} +JSON +cat >"$repo/website/package.json" <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +git -C "$repo" add -- package.json website/package.json +git -C "$repo" commit -q -m "bump rn and a website dep" +expect skip "preview manifest-only prepare failure" feature-broken + +write_commit "multiline import" packages/react/src/hooks/useFetch.native.ts <<'TS' +import { + InteractionManager, +} from 'react-native'; +TS +write_commit "manifest follow-up" website/package.json <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.1", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +expect skip "preview prepare failure with a multiline import" feature-broken "$(git -C "$repo" rev-parse HEAD^)" + +write_commit "playground source beside broken prepare" website/src/components/Playground/transformCode.ts <<'TS' +export const code = 1; +TS +expect build "preview site source still builds when prepare would fail" feature-broken "$(git -C "$repo" rev-parse HEAD^)" + +git -C "$repo" checkout -b feature-fixed "$baseline" >/dev/null 2>&1 +cat >"$repo/package.json" <<'JSON' +{ + "devDependencies": { + "react-native": "0.87.1" + } +} +JSON +cat >"$repo/website/package.json" <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.2.17" + } +} +JSON +mkdir -p "$repo/packages/react/src/hooks" +printf '%s\n' 'export const task = 1;' >"$repo/packages/react/src/hooks/useFetch.native.ts" +git -C "$repo" add -- package.json website/package.json packages/react/src/hooks/useFetch.native.ts +git -C "$repo" commit -q -m "rn 0.87 and drop InteractionManager" +expect build "preview minor website dep builds once the import is gone" feature-fixed + +# Production keeps building website manifest changes, even when prepare +# would fail and even when the ref looks like Renovate. +git -C "$repo" checkout master >/dev/null 2>&1 +cat >"$repo/package.json" <<'JSON' +{ + "devDependencies": { + "react-native": "0.87.1" + } +} +JSON +cat >"$repo/website/package.json" <<'JSON' +{ + "dependencies": { + "monaco-editor": "^0.57.0", + "@typescript/native": "npm:typescript@7.0.2" + }, + "devDependencies": { + "@types/react": "19.3.0" + } +} +JSON +git -C "$repo" add -- package.json website/package.json +git -C "$repo" commit -q -m "master website deps" +expect build "master website deps still build when prepare would fail" master "$(parent)" +expect build "production env renovate ref still builds" renovate/all-minor-patch "$(parent)" production + echo "all vercel-ignore cases passed" From 9085394d0fb97c4a67e645b33eeed10948f5a351 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 20:01:54 +0000 Subject: [PATCH 2/2] ci(website): Simplify to a Renovate manifest-only preview skip Drop the node decision helper, version-range parsing, and the InteractionManager / yarn prepare special case. Renovate previews now skip by excluding website package.json and lockfiles from SITE_PATHS. site-preview.yml returns to master's version. Co-authored-by: Nathaniel Tucker --- .cursor/rules/ci-config.mdc | 4 +- .github/workflows/site-preview.yml | 33 --- website/scripts/vercel-ignore-decide.js | 308 -------------------- website/scripts/vercel-ignore.sh | 90 +----- website/scripts/vercel-ignore.test.sh | 368 ++---------------------- 5 files changed, 27 insertions(+), 776 deletions(-) delete mode 100644 website/scripts/vercel-ignore-decide.js diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 1404c78f9dab..62fbde22266a 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -32,6 +32,4 @@ alwaysApply: false - 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. -- Previews on `renovate/*` skip when the site diff is only non-major dependency bumps under `website/`. A major bump of a dependency declared in a website `package.json` still builds. Real site or docs changes still build. Production (`master`, `rest-hooks-site`, or `VERCEL_ENV=production`) is unchanged. -- The docs-site install command dies with status 2 in `yarn prepare` (`tsc --build`), not in `yarn install`, when `react-native` >= 0.87 is installed and compiled `packages/` sources still import `InteractionManager` (removed from RN's public types in 0.87; `TS2305`). Previews with no site or docs source change skip in that case. Production and real site/docs changes still build. -- `site-preview.yml` runs this script and skips its Vercel build and website typecheck when the script skips. `vercel-ignore-decide.js` classifies dependency bumps and the prepare failure. +- Previews on `renovate/*` skip when the only site changes are website `package.json` or lockfiles; production is unchanged. diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index b44f45d43756..074af3caeb42 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -19,34 +19,7 @@ concurrency: cancel-in-progress: true jobs: - # Same decision the Vercel project runs via ignoreCommand. Exit 0 skips the - # preview (Renovate non-major website bumps, or yarn prepare will exit 2). - preview: - name: Decide whether to build - runs-on: ubuntu-latest - outputs: - skip: ${{ steps.decision.outputs.skip }} - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 2 - - name: Fetch pull request base - run: git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} || true - - name: Run Vercel ignore decision - id: decision - env: - VERCEL_GIT_COMMIT_REF: ${{ github.head_ref }} - VERCEL_GIT_PREVIOUS_SHA: ${{ github.event.pull_request.base.sha }} - run: | - if bash website/scripts/vercel-ignore.sh; then - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - typecheck: - needs: preview runs-on: ubuntu-latest steps: - name: Checkout @@ -56,23 +29,17 @@ jobs: - name: Test Vercel ignore decision run: bash website/scripts/vercel-ignore.test.sh - uses: actions/setup-node@v6 - if: needs.preview.outputs.skip != 'true' with: node-version: '26' cache: 'yarn' - name: Install packages - if: needs.preview.outputs.skip != 'true' run: ./scripts/ci-install.sh website - name: Build package types - if: needs.preview.outputs.skip != 'true' run: yarn ci:build:types - name: Typecheck website - if: needs.preview.outputs.skip != 'true' run: yarn workspace rdc-website typecheck deploy: - needs: preview - if: needs.preview.outputs.skip != 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/website/scripts/vercel-ignore-decide.js b/website/scripts/vercel-ignore-decide.js deleted file mode 100644 index b26ee8604c1d..000000000000 --- a/website/scripts/vercel-ignore-decide.js +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env node -/** - * Helpers for website/scripts/vercel-ignore.sh. - * - * node vercel-ignore-decide.js classify - * Reads OLD_PKG and NEW_PKG. Exits 0 when the package.json edit is only - * non-major dependency bumps. Exits 1 for a major bump or anything else - * (added/removed dependencies, scripts, unparsable ranges). - * - * node vercel-ignore-decide.js prepare-failure - * Exits 0 when `yarn prepare` (`tsc --build`) is expected to exit 2. - * react-native 0.87 dropped InteractionManager from its public types - * (still exported in 0.86.2). packages/react still imports it, so tsc - * reports TS2305 and the Vercel install command - * `cd .. && yarn install && yarn prepare && yarn ci:build && ...` - * dies with status 2 after yarn install has already succeeded. - * Exits 1 when that failure is not expected. - * - * node vercel-ignore-decide.js self-test - */ -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -const DEP_FIELDS = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', - 'resolutions', - 'overrides', -]; - -// First release whose public types omit InteractionManager. -const INTERACTION_MANAGER_REMOVED = [0, 87, 0]; - -function stable(value) { - if (Array.isArray(value)) return value.map(stable); - if (value && typeof value === 'object') { - return Object.keys(value) - .sort() - .map(key => [key, stable(value[key])]); - } - return value; -} - -function rest(pkg) { - const copy = { ...pkg }; - for (const field of DEP_FIELDS) delete copy[field]; - return copy; -} - -function flatten(value, prefix, out) { - if (typeof value === 'string') { - out[prefix] = value; - return; - } - if (value && typeof value === 'object' && !Array.isArray(value)) { - for (const [key, child] of Object.entries(value)) { - flatten(child, prefix ? `${prefix}\0${key}` : key, out); - } - return; - } - if (prefix) out[prefix] = value; -} - -function flattenFields(pkg) { - const out = {}; - for (const field of DEP_FIELDS) { - if (pkg[field] && typeof pkg[field] === 'object') flatten(pkg[field], field, out); - } - return out; -} - -// Version majors mentioned by a specifier, ignoring range punctuation. -// `npm:pkg@version` uses the aliased version. workspace/file/git specs are -// literals. Returns null when the specifier is not a version we can compare. -function classifySpec(spec) { - if (typeof spec !== 'string') return null; - let body = spec.trim(); - const alias = body.match(/^npm:(?:@[^\s@/]+\/[^\s@]+|[^\s@/]+)@(.+)$/); - if (alias) body = alias[1].trim(); - if (/^(workspace:|catalog:|file:|link:|portal:|github:|git\+|git:|https?:)/.test(body)) { - return { kind: 'literal', value: spec.trim() }; - } - const majors = []; - // Build metadata (`+2020`) stays part of the same version so it is not a - // second major. - const re = /(?:^|[^\d.])v?(\d+)(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/g; - let match; - while ((match = re.exec(body))) majors.push(Number(match[1])); - if (majors.length === 0) return null; - return { kind: 'semver', majors }; -} - -// 'nonmajor' | 'major' | 'other' -function compareSpecs(before, after) { - if (before === after) return 'nonmajor'; - const left = classifySpec(before); - const right = classifySpec(after); - if (!left || !right || left.kind !== right.kind) return 'other'; - if (left.kind === 'literal') return left.value === right.value ? 'nonmajor' : 'other'; - if (left.majors.length !== right.majors.length) return 'major'; - for (let i = 0; i < left.majors.length; i++) { - if (left.majors[i] !== right.majors[i]) return 'major'; - } - return 'nonmajor'; -} - -function classify(oldText, newText) { - let oldPkg; - let newPkg; - try { - oldPkg = JSON.parse(oldText); - newPkg = JSON.parse(newText); - } catch { - return 'other'; - } - if (!oldPkg || !newPkg || typeof oldPkg !== 'object' || typeof newPkg !== 'object') { - return 'other'; - } - if (Array.isArray(oldPkg) || Array.isArray(newPkg)) return 'other'; - if (JSON.stringify(stable(rest(oldPkg))) !== JSON.stringify(stable(rest(newPkg)))) { - return 'other'; - } - const before = flattenFields(oldPkg); - const after = flattenFields(newPkg); - const keys = new Set([...Object.keys(before), ...Object.keys(after)]); - let other = false; - for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(before, key)) return 'other'; - if (!Object.prototype.hasOwnProperty.call(after, key)) return 'other'; - if (typeof before[key] !== 'string' || typeof after[key] !== 'string') return 'other'; - const change = compareSpecs(before[key], after[key]); - if (change === 'major') return 'major'; - if (change === 'other') other = true; - } - return other ? 'other' : 'nonmajor'; -} - -function versionAtLeast(spec, target) { - const match = String(spec).match(/(\d+)\.(\d+)(?:\.(\d+))?/); - if (!match) return false; - const ver = [Number(match[1]), Number(match[2]), match[3] === undefined ? 0 : Number(match[3])]; - for (let i = 0; i < 3; i++) { - if (ver[i] !== target[i]) return ver[i] > target[i]; - } - return true; -} - -function declaredReactNative(pkg) { - const resolutions = pkg.resolutions || {}; - const overrides = pkg.overrides || {}; - const pinned = resolutions['react-native'] || resolutions['**/react-native'] || overrides['react-native']; - if (typeof pinned === 'string') return pinned; - const deps = pkg.dependencies || {}; - const dev = pkg.devDependencies || {}; - if (typeof deps['react-native'] === 'string') return deps['react-native']; - if (typeof dev['react-native'] === 'string') return dev['react-native']; - return ''; -} - -function importsInteractionManager(src) { - const re = /(?:import|export)\s+(?:type\s+)?([\s\S]{0,500}?)\s+from\s+['"]react-native['"]/g; - let match; - while ((match = re.exec(src))) { - if (/\bInteractionManager\b/.test(match[1])) return true; - } - return false; -} - -function compiledSourceImportsInteractionManager(root) { - const packages = path.join(root, 'packages'); - const stack = [packages]; - while (stack.length) { - const dir = stack.pop(); - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if ( - entry.name === 'node_modules' || - entry.name === 'lib' || - entry.name === 'dist' || - entry.name === 'legacy' || - entry.name === '__tests__' - ) { - continue; - } - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - stack.push(full); - continue; - } - if (!/\.(?:[cm]?ts|tsx)$/.test(entry.name) || /\.(?:test|spec)\.[^.]+$/.test(entry.name)) { - continue; - } - let src; - try { - src = fs.readFileSync(full, 'utf8'); - } catch { - continue; - } - if (src.includes('InteractionManager') && importsInteractionManager(src)) return true; - } - } - return false; -} - -function prepareWillFail(root) { - let pkg; - try { - pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - } catch { - return false; - } - if (!pkg || typeof pkg !== 'object') return false; - if (!versionAtLeast(declaredReactNative(pkg), INTERACTION_MANAGER_REMOVED)) return false; - return compiledSourceImportsInteractionManager(root); -} - -function selfTest() { - const os = require('os'); - const assert = (cond, msg) => { - if (!cond) { - console.error(`self-test failed: ${msg}`); - process.exit(1); - } - }; - const withDeps = (deps, extra = {}) => JSON.stringify({ ...extra, ...deps }); - - assert(classify(withDeps({ dependencies: { a: '^1.2.3' } }), withDeps({ dependencies: { a: '^1.4.0' } })) === 'nonmajor', 'minor'); - assert(classify(withDeps({ dependencies: { a: '1.2.3' } }), withDeps({ dependencies: { a: '1.2.4' } })) === 'nonmajor', 'patch'); - assert(classify(withDeps({ dependencies: { a: '^1.2.3' } }), withDeps({ dependencies: { a: '^2.0.0' } })) === 'major', 'major'); - assert(classify(withDeps({ dependencies: { m: '^0.56.0' } }), withDeps({ dependencies: { m: '^0.57.0' } })) === 'nonmajor', '0.x minor'); - assert(classify(withDeps({ dependencies: { m: '^0.56.0' } }), withDeps({ dependencies: { m: '^1.0.0' } })) === 'major', '0.x to 1'); - assert(classify(withDeps({ devDependencies: { '@types/react': '19.2.17' } }), withDeps({ devDependencies: { '@types/react': '19.3.0' } })) === 'nonmajor', 'types minor'); - assert(classify(withDeps({ devDependencies: { '@types/react': '19.2.17' } }), withDeps({ devDependencies: { '@types/react': '20.0.0' } })) === 'major', 'types major'); - assert(classify(withDeps({ dependencies: { t: 'npm:typescript@7.0.2' } }), withDeps({ dependencies: { t: 'npm:typescript@7.0.3' } })) === 'nonmajor', 'alias patch'); - assert(classify(withDeps({ dependencies: { t: 'npm:@typescript/typescript6@6.0.2' } }), withDeps({ dependencies: { t: 'npm:@typescript/typescript6@7.0.0' } })) === 'major', 'scoped alias major'); - assert(classify(withDeps({ dependencies: { a: '^4.8.0-rc.0' } }), withDeps({ dependencies: { a: '^4.9.0' } })) === 'nonmajor', 'prerelease minor'); - assert(classify(withDeps({ dependencies: { a: '^4.8.0-rc.0' } }), withDeps({ dependencies: { a: '^5.0.0-rc.1' } })) === 'major', 'prerelease major'); - assert(classify(withDeps({ dependencies: { a: '1.2.3+2020' } }), withDeps({ dependencies: { a: '1.2.4+2021' } })) === 'nonmajor', 'build metadata'); - assert(classify(withDeps({ dependencies: { w: 'workspace:*', a: '1.2.3' } }), withDeps({ dependencies: { w: 'workspace:*', a: '1.2.4' } })) === 'nonmajor', 'workspace untouched'); - assert(classify(withDeps({ dependencies: { a: '1.0.0' } }), withDeps({ dependencies: { a: '1.0.0', b: '1.0.0' } })) === 'other', 'added dep'); - assert(classify(withDeps({ dependencies: { a: '1.0.0', b: '1.0.0' } }), withDeps({ dependencies: { a: '1.0.0' } })) === 'other', 'removed dep'); - assert( - classify(withDeps({ dependencies: { a: '1.0.0' } }, { scripts: { build: 'a' } }), withDeps({ dependencies: { a: '1.0.1' } }, { scripts: { build: 'b' } })) === 'other', - 'script change', - ); - assert(classify(withDeps({ resolutions: { foo: '7.1.2' } }), withDeps({ resolutions: { foo: '8.0.0' } })) === 'major', 'resolution major'); - assert(classify(withDeps({ overrides: { foo: { bar: '1.2.3' } } }), withDeps({ overrides: { foo: { bar: '1.2.4' } } })) === 'nonmajor', 'nested override patch'); - assert(classify(withDeps({ overrides: { foo: { bar: '1.2.3' } } }), withDeps({ overrides: { foo: { bar: '2.0.0' } } })) === 'major', 'nested override major'); - assert(classify('{', '{') === 'other', 'invalid json'); - - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vercel-ignore-')); - const hook = path.join(root, 'packages/react/src/hooks'); - fs.mkdirSync(hook, { recursive: true }); - const source = path.join(hook, 'useFetch.native.ts'); - const writePkg = version => { - fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { 'react-native': version } })); - }; - const importing = "import { InteractionManager } from 'react-native';\n"; - try { - writePkg('0.86.2'); - fs.writeFileSync(source, importing); - assert(prepareWillFail(root) === false, '0.86 still exports InteractionManager'); - writePkg('0.87.0'); - assert(prepareWillFail(root) === true, '0.87.0 removed the export'); - writePkg('0.87.1'); - assert(prepareWillFail(root) === true, '0.87.1 removed the export'); - fs.writeFileSync(source, "import {\n InteractionManager,\n} from 'react-native';\n"); - assert(prepareWillFail(root) === true, 'multiline import'); - fs.writeFileSync(source, "export const x = 1;\n"); - assert(prepareWillFail(root) === false, 'import removed'); - fs.mkdirSync(path.join(root, 'packages/react/src/__tests__'), { recursive: true }); - fs.writeFileSync(path.join(root, 'packages/react/src/__tests__/useFetch.native.tsx'), importing); - assert(prepareWillFail(root) === false, 'test import is not compiled by tsc --build'); - writePkg('^0.86.2'); - fs.writeFileSync(source, importing); - assert(prepareWillFail(root) === false, 'range whose minimum still has the export'); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -} - -function main() { - const cmd = process.argv[2]; - if (cmd === 'self-test') { - selfTest(); - return; - } - if (cmd === 'classify') { - const result = classify(process.env.OLD_PKG || '', process.env.NEW_PKG || ''); - process.exit(result === 'nonmajor' ? 0 : 1); - } - if (cmd === 'prepare-failure') { - process.exit(prepareWillFail(process.cwd()) ? 0 : 1); - } - console.error(`usage: vercel-ignore-decide.js `); - process.exit(1); -} - -main(); diff --git a/website/scripts/vercel-ignore.sh b/website/scripts/vercel-ignore.sh index 7717e8d0924b..2c78683a45bb 100755 --- a/website/scripts/vercel-ignore.sh +++ b/website/scripts/vercel-ignore.sh @@ -8,27 +8,9 @@ # 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. -# -# Renovate previews (renovate/*) skip when every published-site change is a -# non-major dependency bump under website/. A major bump of a package declared -# in a website package.json still builds, unless yarn prepare is expected to -# fail before the site compiles (below). Real site or docs changes still -# build, including on a Renovate branch. Production (master, rest-hooks-site, -# or VERCEL_ENV=production) is never skipped by these preview rules. -# -# The docs-site install command is `cd .. && yarn install && yarn prepare && -# yarn ci:build && cd website && ...`. yarn install succeeds; yarn prepare -# runs `tsc --build` and exits 2 when react-native >= 0.87 is installed, -# because that release dropped InteractionManager and packages/react still -# imports it (TS2305). Previews with no site or docs source change skip in -# that case instead of starting a build that dies in the install command. -# A real site or docs change still builds, so a site-source failure is not -# hidden. set -u -DECIDE_JS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vercel-ignore-decide.js" - # 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=( @@ -52,76 +34,12 @@ skip() { cd "$(git rev-parse --show-toplevel)" || build "cannot find repo root" -preview_context() { - [[ "${VERCEL_ENV:-}" == production ]] && return 1 - [[ "${VERCEL_GIT_COMMIT_REF:-}" =~ ^(master|rest-hooks-site)$ ]] && return 1 - return 0 -} - -is_website_manifest() { - [[ "$1" =~ ^website/(.+/)?package\.json$ ]] && return 0 - [[ "$1" =~ ^website/(.+/)?(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$ ]] -} - -# True when a changed path is published site or docs source, not a website -# dependency manifest. -site_source_changed() { - local f - while IFS= read -r f; do - [ -n "$f" ] || continue - is_website_manifest "$f" || return 0 - done <<<"$1" - return 1 -} - -# True when every changed path is a website lockfile or a package.json whose -# only edits are non-major dependency bumps. -renovate_nonmajor_only() { - local base="$1" head="$2" f old new - while IFS= read -r f; do - [ -n "$f" ] || continue - if [[ "$f" =~ ^website/(.+/)?(yarn\.lock|package-lock\.json|pnpm-lock\.yaml|npm-shrinkwrap\.json)$ ]]; then - continue - fi - if [[ "$f" =~ ^website/(.+/)?package\.json$ ]]; then - old="$(git show "$base:$f" 2>/dev/null)" || return 1 - new="$(git show "$head:$f" 2>/dev/null)" || return 1 - OLD_PKG="$old" NEW_PKG="$new" node "$DECIDE_JS" classify >/dev/null || return 1 - continue - fi - return 1 - done <<<"$3" - return 0 -} - -# True when yarn prepare would exit 2 for the known InteractionManager break. -# A missing node, or any other uncertainty, returns false so the build runs. -expected_prepare_failure() { - command -v node >/dev/null 2>&1 || return 1 - node "$DECIDE_JS" prepare-failure -} - -# Skip a preview that cannot show a site change: Renovate non-major website -# dependency bumps, or an install that dies in yarn prepare before the site -# compiles. Returns without skipping when the preview should build. -maybe_skip_preview() { - preview_context || return 0 - site_source_changed "$3" && return 0 - if [[ "${VERCEL_GIT_COMMIT_REF:-}" == renovate/* ]] && renovate_nonmajor_only "$1" "$2" "$3"; then - skip "$4: renovate non-major website dependency bumps" - fi - if expected_prepare_failure; then - skip "$4: yarn prepare would exit 2 (react-native dropped InteractionManager)" - fi -} - # 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" - maybe_skip_preview "$1" "$2" "$files" "$3" build "$3: ${files//$'\n'/, }" } @@ -163,6 +81,14 @@ if [[ "${VERCEL_GIT_COMMIT_REF:-}" =~ ^(master|rest-hooks-site)$ || "${VERCEL_EN build "no previous production deploy to compare" fi +# Renovate previews skip when only website dependency manifests or lockfiles +# changed. Site or docs source changes still build. +if [[ "${VERCEL_GIT_COMMIT_REF:-}" == renovate/* ]]; then + for f in package.json yarn.lock package-lock.json pnpm-lock.yaml npm-shrinkwrap.json; do + SITE_PATHS+=(":(exclude,glob)website/**/$f") + done +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. diff --git a/website/scripts/vercel-ignore.test.sh b/website/scripts/vercel-ignore.test.sh index 5f87f9e93438..b42eccc7ab51 100755 --- a/website/scripts/vercel-ignore.test.sh +++ b/website/scripts/vercel-ignore.test.sh @@ -4,7 +4,6 @@ set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" script="$root/website/scripts/vercel-ignore.sh" -node "$root/website/scripts/vercel-ignore-decide.js" self-test repo="$(mktemp -d)" trap 'rm -rf "$repo"' EXIT @@ -27,14 +26,6 @@ commit() { git -C "$repo" commit -m "$msg" >/dev/null } -write_commit() { - local msg="$1" path="$2" - mkdir -p "$repo/$(dirname "$path")" - cat >"$repo/$path" - git -C "$repo" add -- "$path" - git -C "$repo" commit -q -m "$msg" -} - # expect [previous-sha] [vercel-env] expect() { local want="$1" name="$2" ref="$3" out rc code=1 @@ -189,350 +180,27 @@ 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 -# --- renovate previews and the yarn prepare / InteractionManager failure --- -# react-native 0.87 removed InteractionManager. yarn prepare (tsc --build) -# then exits 2, which is the Vercel install command's status. Baseline keeps -# 0.86 so the Renovate rule and the prepare-failure rule can be told apart. +# --- Renovate previews ignore website dependency manifests and lockfiles --- +git -C "$repo" checkout -b renovate/docusaurus master >/dev/null 2>&1 +commit "bump docusaurus" website/package.json +expect skip "renovate website package.json" renovate/docusaurus +expect build "renovate ref in production env" renovate/docusaurus "$(parent)" production +commit "bump lockfiles" website/yarn.lock website/examples/demo/package.json website/examples/demo/pnpm-lock.yaml +expect skip "renovate nested manifests and lockfiles since last deploy" renovate/docusaurus "$(parent)" git -C "$repo" checkout master >/dev/null 2>&1 -write_commit "rn baseline" package.json <<'JSON' -{ - "devDependencies": { - "react-native": "0.86.2" - } -} -JSON -write_commit "website deps" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.56.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -write_commit "native hook" packages/react/src/hooks/useFetch.native.ts <<'TS' -import { InteractionManager } from 'react-native'; -TS -baseline="$(git -C "$repo" rev-parse HEAD)" - -git -C "$repo" checkout -b renovate/all-minor-patch "$baseline" >/dev/null 2>&1 -write_commit "pkg: minor website deps" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.3.0" - } -} -JSON -expect skip "renovate minor and patch website deps" renovate/all-minor-patch - -minor_sha="$(git -C "$repo" rev-parse HEAD)" -write_commit "docs page on renovate" docs/core/api/Controller.md <<'MD' -# Controller -MD -expect build "renovate docs after a minor bump" renovate/all-minor-patch "$minor_sha" - -docs_sha="$(git -C "$repo" rev-parse HEAD)" -write_commit "pkg: another patch" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.1", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.3.0" - } -} -JSON -expect skip "renovate patch after a site deploy" renovate/all-minor-patch "$docs_sha" - -git -C "$repo" checkout -b renovate/major "$baseline" >/dev/null 2>&1 -write_commit "pkg: major website dep" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^1.0.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -expect build "renovate major website dependency" renovate/major - -git -C "$repo" checkout -b renovate/dev-major "$baseline" >/dev/null 2>&1 -write_commit "pkg: major website devDep" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.56.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "20.0.0" - } -} -JSON -expect build "renovate major website devDependency" renovate/dev-major - -git -C "$repo" checkout -b renovate/alias "$baseline" >/dev/null 2>&1 -write_commit "pkg: major npm alias" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.56.0", - "@typescript/native": "npm:typescript@8.0.0" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -expect build "renovate major npm alias" renovate/alias - -git -C "$repo" checkout -b renovate/resolution "$baseline" >/dev/null 2>&1 -write_commit "pkg: major resolution" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.56.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - }, - "resolutions": { - "serialize-javascript": "8.0.0" - } -} -JSON -expect build "renovate major website resolution" renovate/resolution - -git -C "$repo" checkout -b renovate/added "$baseline" >/dev/null 2>&1 -write_commit "pkg: add website dep" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.56.0", - "@typescript/native": "npm:typescript@7.0.2", - "left-pad": "1.0.0" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -expect build "renovate added website dependency" renovate/added - -git -C "$repo" checkout -b renovate/scripts "$baseline" >/dev/null 2>&1 -write_commit "pkg: website script" website/package.json <<'JSON' -{ - "scripts": { - "build": "docusaurus build" - }, - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -expect build "renovate website package.json script change" renovate/scripts +commit "master site for renovate" docs/core/api/Renovate.md +git -C "$repo" checkout renovate/docusaurus >/dev/null 2>&1 +git -C "$repo" merge --no-edit master >/dev/null +expect skip "renovate merge of master" renovate/docusaurus +commit "renovate site source" website/src/pages/index.js +expect build "renovate with site source" renovate/docusaurus "$(parent)" -git -C "$repo" checkout -b renovate/lock "$baseline" >/dev/null 2>&1 -write_commit "pkg: website lock" website/yarn.lock <<'LOCK' -# lock 1 -LOCK -expect skip "renovate website lockfile only" renovate/lock +git -C "$repo" checkout -b deps master >/dev/null 2>&1 +commit "manual bump" website/package.json +expect build "non-renovate website package.json" deps -# Master gained site commits, then a Renovate branch with only a minor bump -# merges master. The preview diff is the branch, not the incoming site commit. -git -C "$repo" checkout master >/dev/null 2>&1 -write_commit "master site moves again" website/src/pages/index.js <<'JS' -export default function Home() {} -JS -moved="$(git -C "$repo" rev-parse HEAD)" -git -C "$repo" checkout -b renovate/merged "$baseline" >/dev/null 2>&1 -write_commit "pkg: minor before merge" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -git -C "$repo" merge --no-edit "$moved" >/dev/null -expect skip "renovate merge of master into a minor dep branch" renovate/merged - -# Non-renovate preview of a minor website bump still builds. -git -C "$repo" checkout -b feature-deps "$baseline" >/dev/null 2>&1 -write_commit "human minor website dep" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -expect build "preview non-renovate minor website dep" feature-deps - -# react-native 0.87 plus the import: yarn prepare exits 2. Manifest-only -# previews skip. Site source, docs, and production still build. -git -C "$repo" checkout -b renovate/rn "$baseline" >/dev/null 2>&1 -cat >"$repo/package.json" <<'JSON' -{ - "devDependencies": { - "react-native": "0.87.1" - } -} -JSON -cat >"$repo/website/package.json" <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.3.0" - } -} -JSON -git -C "$repo" add -- package.json website/package.json -git -C "$repo" commit -q -m "pkg: Update all non-major dependencies" -expect skip "renovate minor with prepare failure" renovate/rn - -git -C "$repo" checkout -b renovate/rn-major "$baseline" >/dev/null 2>&1 -cat >"$repo/package.json" <<'JSON' -{ - "devDependencies": { - "react-native": "0.87.1" - } -} -JSON -cat >"$repo/website/package.json" <<'JSON' -{ - "dependencies": { - "monaco-editor": "^1.0.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -git -C "$repo" add -- package.json website/package.json -git -C "$repo" commit -q -m "pkg: major website dep on rn 0.87" -expect skip "renovate major skipped when prepare will fail" renovate/rn-major - -write_commit "docs beside broken prepare" docs/rest/api/Entity.md <<'MD' -# Entity -MD -expect build "renovate docs still build when prepare will fail" renovate/rn-major - -git -C "$repo" checkout -b feature-broken "$baseline" >/dev/null 2>&1 -cat >"$repo/package.json" <<'JSON' -{ - "devDependencies": { - "react-native": "0.87.0" - } -} -JSON -cat >"$repo/website/package.json" <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -git -C "$repo" add -- package.json website/package.json -git -C "$repo" commit -q -m "bump rn and a website dep" -expect skip "preview manifest-only prepare failure" feature-broken - -write_commit "multiline import" packages/react/src/hooks/useFetch.native.ts <<'TS' -import { - InteractionManager, -} from 'react-native'; -TS -write_commit "manifest follow-up" website/package.json <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.1", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -expect skip "preview prepare failure with a multiline import" feature-broken "$(git -C "$repo" rev-parse HEAD^)" - -write_commit "playground source beside broken prepare" website/src/components/Playground/transformCode.ts <<'TS' -export const code = 1; -TS -expect build "preview site source still builds when prepare would fail" feature-broken "$(git -C "$repo" rev-parse HEAD^)" - -git -C "$repo" checkout -b feature-fixed "$baseline" >/dev/null 2>&1 -cat >"$repo/package.json" <<'JSON' -{ - "devDependencies": { - "react-native": "0.87.1" - } -} -JSON -cat >"$repo/website/package.json" <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.2.17" - } -} -JSON -mkdir -p "$repo/packages/react/src/hooks" -printf '%s\n' 'export const task = 1;' >"$repo/packages/react/src/hooks/useFetch.native.ts" -git -C "$repo" add -- package.json website/package.json packages/react/src/hooks/useFetch.native.ts -git -C "$repo" commit -q -m "rn 0.87 and drop InteractionManager" -expect build "preview minor website dep builds once the import is gone" feature-fixed - -# Production keeps building website manifest changes, even when prepare -# would fail and even when the ref looks like Renovate. git -C "$repo" checkout master >/dev/null 2>&1 -cat >"$repo/package.json" <<'JSON' -{ - "devDependencies": { - "react-native": "0.87.1" - } -} -JSON -cat >"$repo/website/package.json" <<'JSON' -{ - "dependencies": { - "monaco-editor": "^0.57.0", - "@typescript/native": "npm:typescript@7.0.2" - }, - "devDependencies": { - "@types/react": "19.3.0" - } -} -JSON -git -C "$repo" add -- package.json website/package.json -git -C "$repo" commit -q -m "master website deps" -expect build "master website deps still build when prepare would fail" master "$(parent)" -expect build "production env renovate ref still builds" renovate/all-minor-patch "$(parent)" production +commit "master bump" website/package.json website/yarn.lock +expect build "master website manifest" master "$(parent)" echo "all vercel-ignore cases passed"