diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 9c18a97e2be6..ee55396f3216 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -29,9 +29,16 @@ alwaysApply: false - `skills.yml` `paths` must cover every input of `website/framework-docs/skillReferences.mjs` (docs, skill manifests, the generator and its deps). - `editor-types.yml` reruns `yarn copy:websitetypes` and fails if `website/src/components/Playground/editor-types` changes. It needs the `website` workspace (for deps like `bignumber.js`), which CircleCI's `setup` drops. Its `paths` must cover every input of `scripts/copywebsitetypes.sh`. - `site-preview.yml` runs one `build` job (one install for the typecheck and the build). It builds the site directly (no Vercel CLI, only the packages it imports via `ci:build:website`, `VERCEL_ENV=preview` to include drafts), restores Docusaurus' webpack cache (only master pushes save it, so PRs share one entry), and fails on any `[WARNING]`/`[ERROR]` line. Broken links are `warn` in `docusaurus.config.ts` so this check catches them without failing Vercel deploys. -- `site-preview.yml`/`site-release.yml` `paths` (`website/**`, `docs/{core,rest,graphql}/**`) must match `SITE_PATHS` in `website/scripts/vercel-ignore.sh`. +- Production docs deploys come from Vercel's Git integration only (gated by `vercel-ignore.sh`); there is no Actions deploy workflow. Vercel clones ~10 commits deep, so `website/scripts/deepenGitHistory.cjs` (called from `docusaurus.config.ts`) fetches 800 more for the "Last updated" dates. +- `site-preview.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:`. +- Never cancel a run on master: a cancelled check marks the commit red, which hurts npm search scoring. PR runs cancel superseded ones (`group: -${{ github.head_ref || github.run_id }}`, `cancel-in-progress: true`); push runs get a unique group. Runs that must not overlap (`release.yml`, `beta-release.yml`) use a shared group with `queue: max`, which keeps up to 100 pending runs in order instead of cancelling all but one (GitHub rejects it alongside `cancel-in-progress: true`; actionlint 1.7.12 doesn't know the key yet). The benchmark push groups still use the default single pending slot. +- Report-style workflows (`benchmark*.yml`, `bundle_size.yml`, `codeql-analysis.yml`) skip draft PRs with a job-level `if: ${{ !github.event.pull_request.draft }}` and list `ready_for_review` in `pull_request.types`, so they run once a PR is marked ready. Correctness checks (`editor-types`, `skills`, `website`) still run on drafts. +- `paths` leave out what a workflow never reads, so test- or docs-only edits under `packages/` don't fan out: `__tests__/`, `typescript-tests/`, `src-*-types/` (legacy types) and `*.md`. `bundle_size.yml` also skips packages `examples/test-bundlesize` doesn't bundle (graphql, test, vue). +- CodeQL triggers only on shipped source (`packages/*/src/**`, `packages/*/node.mjs`). `bundle_size.yml` is PR-only: on push the action measures but has nowhere to report. +- Bundle Size and the benchmarks list `yarn.lock` in `paths` but gate their main job on the reusable `dependency-gate.yml` (`scripts/ci-deps-relevant.mjs`). It runs the job when a non-manifest file in the workflow's paths changed, a manifest of the measured workspaces (or a workspace package they ship with) changed, or the yarn.lock resolutions reachable from their dependencies or the babel/browserslist/core-js build tooling changed. Bumps of test, lint, React Native or website tooling skip it; anything it can't classify runs. The gate reads the caller's `on..paths` itself (via `github.workflow_ref` and `yq`), so there's one list. Root `devDependencies` aren't walked: add a new root build dependency to `BUILD_TOOLS`. +- Renovate (`.github/renovate.json`) uses `rebaseWhen: conflicted`: master's branch protection requires up-to-date branches, which makes the default rebase every Renovate PR (and rerun all of its CI) on each master push. Update the branch before merging a Renovate PR; if that update isn't merged, Renovate treats the branch as edited and stops rebasing it until its PR's rebase checkbox is ticked. ## Vercel docs site (`website/scripts/vercel-ignore.sh`) diff --git a/.github/renovate.json b/.github/renovate.json index 3d8bfb3530f9..cd44c6521870 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -4,6 +4,7 @@ "commitMessagePrefix": "pkg: ", "commitMessageTopic": "`{{depName}}`", "dependencyDashboardApproval": true, + "rebaseWhen": "conflicted", "rangeStrategy": "auto", "ignoreDeps": [ "whatwg-fetch", diff --git a/.github/workflows/benchmark-react.yml b/.github/workflows/benchmark-react.yml index 4c9638572d62..8483962fb449 100644 --- a/.github/workflows/benchmark-react.yml +++ b/.github/workflows/benchmark-react.yml @@ -2,6 +2,7 @@ name: Benchmark React on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: - master paths: @@ -10,6 +11,11 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' + - '!packages/**/__tests__/**' + - '!examples/benchmark-react/**/*.md' + - 'yarn.lock' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/benchmark-react.yml' push: branches: @@ -20,6 +26,11 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/normalizr/src/**' - 'examples/benchmark-react/**' + - '!packages/**/__tests__/**' + - '!examples/benchmark-react/**/*.md' + - 'yarn.lock' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/benchmark-react.yml' concurrency: @@ -31,7 +42,16 @@ permissions: pull-requests: write jobs: + # Skips dependency bumps that can't change what this measures + changes: + if: ${{ !github.event.pull_request.draft }} + uses: ./.github/workflows/dependency-gate.yml + with: + workspaces: examples/benchmark-react + benchmark-react: + needs: changes + if: ${{ needs.changes.outputs.relevant == 'true' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/benchmark-spread.yml b/.github/workflows/benchmark-spread.yml index ff250e4f25e4..ce350be1c29c 100644 --- a/.github/workflows/benchmark-spread.yml +++ b/.github/workflows/benchmark-spread.yml @@ -7,15 +7,20 @@ name: Benchmark Spread on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: - master paths: - 'packages/core/src/state/**' - 'packages/normalizr/src/normalize/**' - 'packages/endpoint/src/schemas/EntityMixin.ts' + - '!packages/**/__tests__/**' - 'examples/benchmark/spread.js' - 'examples/benchmark/spread-scenarios.js' - 'examples/benchmark/schemas.js' + - 'yarn.lock' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/benchmark-spread.yml' push: branches: @@ -24,9 +29,13 @@ on: - 'packages/core/src/state/**' - 'packages/normalizr/src/normalize/**' - 'packages/endpoint/src/schemas/EntityMixin.ts' + - '!packages/**/__tests__/**' - 'examples/benchmark/spread.js' - 'examples/benchmark/spread-scenarios.js' - 'examples/benchmark/schemas.js' + - 'yarn.lock' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/benchmark-spread.yml' permissions: contents: write @@ -37,8 +46,16 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - benchmark-spread: + # Skips dependency bumps that can't change what this measures + changes: + if: ${{ !github.event.pull_request.draft }} + uses: ./.github/workflows/dependency-gate.yml + with: + workspaces: examples/benchmark + benchmark-spread: + needs: changes + if: ${{ needs.changes.outputs.relevant == 'true' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 19411426e69c..096a0e05971b 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -2,6 +2,7 @@ name: Benchmark on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: - master paths: @@ -9,6 +10,11 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/core/src/**' - 'examples/benchmark/**' + - '!packages/**/__tests__/**' + - '!examples/benchmark/**/*.md' + - 'yarn.lock' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/benchmark.yml' push: branches: @@ -18,6 +24,11 @@ on: - 'packages/endpoint/src/schemas/**' - 'packages/core/src/**' - 'examples/benchmark/**' + - '!packages/**/__tests__/**' + - '!examples/benchmark/**/*.md' + - 'yarn.lock' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/benchmark.yml' permissions: contents: write @@ -28,8 +39,16 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - benchmark: + # Skips dependency bumps that can't change what this measures + changes: + if: ${{ !github.event.pull_request.draft }} + uses: ./.github/workflows/dependency-gate.yml + with: + workspaces: examples/benchmark + benchmark: + needs: changes + if: ${{ needs.changes.outputs.relevant == 'true' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 84eb3573cca3..9b8b79785ccc 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -12,7 +12,10 @@ on: default: 'beta' type: string -concurrency: ${{ github.workflow }}-${{ github.ref }} +# Queue every run instead of cancelling pending ones (red on the branch) +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max jobs: beta-release: diff --git a/.github/workflows/bundle_size.yml b/.github/workflows/bundle_size.yml index b731b8093c2f..b25bebc8f37d 100644 --- a/.github/workflows/bundle_size.yml +++ b/.github/workflows/bundle_size.yml @@ -1,30 +1,41 @@ name: Bundle Size on: + # PR-only: on push the action measures but has nowhere to report pull_request: - # The branches below must be a subset of the branches above + types: [opened, synchronize, reopened, ready_for_review] branches: [ master ] paths: - 'packages/**' + - '!packages/**/__tests__/**' + - '!packages/*/typescript-tests/**' + - '!packages/*/src-*-types/**' + - '!packages/**/*.md' + # not bundled by examples/test-bundlesize + - '!packages/graphql/**' + - '!packages/test/**' + - '!packages/vue/**' - 'yarn.lock' - 'examples/test-bundlesize/**' - - '.github/workflows/bundle_size.yml' - push: - branches: - - master - paths: - - 'packages/**' - - 'yarn.lock' - - 'examples/test-bundlesize/**' + - 'scripts/ci-deps-relevant.mjs' + - '.github/workflows/dependency-gate.yml' - '.github/workflows/bundle_size.yml' concurrency: - group: bundle-size-${{ github.head_ref || github.ref }} + group: bundle-size-${{ github.head_ref }} cancel-in-progress: true jobs: - build: + # Skips dependency bumps that can't change what this measures + changes: + if: ${{ !github.event.pull_request.draft }} + uses: ./.github/workflows/dependency-gate.yml + with: + workspaces: examples/test-bundlesize + build: + needs: changes + if: ${{ needs.changes.outputs.relevant == 'true' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ca308aa719df..965531d64d3d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -11,24 +11,34 @@ # name: "CodeQL" +# Shipped source only: manifests, docs and tests can't add alerts that matter on: push: branches: [ master ] paths: - - 'packages/**' + - 'packages/*/src/**' + - 'packages/*/node.mjs' + - '!packages/**/__tests__/**' + - '.github/workflows/codeql-analysis.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] # The branches below must be a subset of the branches above branches: [ master ] paths: - - 'packages/**' + - 'packages/*/src/**' + - 'packages/*/node.mjs' + - '!packages/**/__tests__/**' + - '.github/workflows/codeql-analysis.yml' schedule: - cron: '25 22 * * 5' permissions: contents: read +# PRs cancel superseded runs. Push runs get their own group, so master never +# shows a cancelled (red) run. concurrency: - group: codeql-${{ github.head_ref || github.ref }} + group: codeql-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: @@ -38,6 +48,7 @@ jobs: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/autobuild to send a status report name: Analyze + if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/dependency-gate.yml b/.github/workflows/dependency-gate.yml new file mode 100644 index 000000000000..6c19689279f4 --- /dev/null +++ b/.github/workflows/dependency-gate.yml @@ -0,0 +1,38 @@ +name: dependency gate +# Reusable: tells a workflow whether its change can affect what it measures, +# so dependency bumps of unrelated tooling skip it. See scripts/ci-deps-relevant.mjs. +on: + workflow_call: + inputs: + workspaces: + description: Comma-separated workspaces the job builds and runs + required: true + type: string + outputs: + relevant: + value: ${{ jobs.check.outputs.relevant }} + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.deps.outputs.relevant }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + - id: deps + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + WORKSPACES: ${{ inputs.workspaces }} + run: | + # A missing base makes the script fail open + git fetch -q --depth=1 origin "$BASE" || true + # The caller's own `paths` filter (github.workflow_ref names the + # caller). Unreadable means none: any changed file counts. + workflow="${GITHUB_WORKFLOW_REF#*/*/}" + mapfile -t paths < <(yq -r ".on.${GITHUB_EVENT_NAME}.paths[]" "${workflow%@*}" || true) + node scripts/ci-deps-relevant.mjs "$BASE" "$WORKSPACES" "${paths[@]}" diff --git a/.github/workflows/editor-types.yml b/.github/workflows/editor-types.yml index b1934a98303f..8a83469f5c13 100644 --- a/.github/workflows/editor-types.yml +++ b/.github/workflows/editor-types.yml @@ -7,6 +7,10 @@ on: # Inputs of scripts/copywebsitetypes.sh paths: - 'packages/**' + - '!packages/**/__tests__/**' + - '!packages/*/typescript-tests/**' + - '!packages/*/src-*-types/**' + - '!packages/**/*.md' - 'scripts/copywebsitetypes.sh' - 'scripts/strip-dts-comments.mjs' - 'scripts/globals.ts' @@ -22,6 +26,10 @@ on: - master paths: - 'packages/**' + - '!packages/**/__tests__/**' + - '!packages/*/typescript-tests/**' + - '!packages/*/src-*-types/**' + - '!packages/**/*.md' - 'scripts/copywebsitetypes.sh' - 'scripts/strip-dts-comments.mjs' - 'scripts/globals.ts' @@ -33,8 +41,10 @@ on: - 'tsconfig*.json' - '.github/workflows/editor-types.yml' +# PRs cancel superseded runs. Push runs get their own group, so master never +# shows a cancelled (red) run. concurrency: - group: editor-types-${{ github.head_ref || github.ref }} + group: editor-types-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e19bd2e50bce..74b6149eb580 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,10 @@ on: branches: - master -concurrency: ${{ github.workflow }}-${{ github.ref }} +# Queue every run instead of cancelling pending ones (red on the branch) +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + queue: max jobs: release: diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 3aaf10c6e148..e85e32c0cca8 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -21,8 +21,10 @@ on: - 'docs/graphql/**' - '.github/workflows/site-preview.yml' +# PRs cancel superseded runs. Push runs get their own group, so master never +# shows a cancelled (red) run. concurrency: - group: site-preview-${{ github.head_ref || github.ref }} + group: site-preview-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: diff --git a/.github/workflows/site-release.yml b/.github/workflows/site-release.yml deleted file mode 100644 index 651a5dd5c242..000000000000 --- a/.github/workflows/site-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Vercel Release Deployment -env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} -on: - push: - branches: - - master - - rest-hooks-site - # Published site only. Keep in sync with website/scripts/vercel-ignore.sh. - paths: - - 'website/**' - - 'docs/core/**' - - 'docs/rest/**' - - 'docs/graphql/**' - - '.github/workflows/site-release.yml' - -concurrency: - group: site-release-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 800 - - name: Install Vercel CLI - run: npm install --global vercel@latest - - name: Pull Vercel Environment Information - run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} - - name: Build Project Artifacts - run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy Project Artifacts to Vercel - continue-on-error: true - run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/skills.yml b/.github/workflows/skills.yml index 2f23ab57ab8e..a486315ea501 100644 --- a/.github/workflows/skills.yml +++ b/.github/workflows/skills.yml @@ -25,8 +25,10 @@ on: - '.github/workflows/skills.yml' - '.gitattributes' +# PRs cancel superseded runs. Push runs get their own group, so master never +# shows a cancelled (red) run. concurrency: - group: skills-${{ github.head_ref || github.ref }} + group: skills-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: diff --git a/scripts/ci-deps-relevant.mjs b/scripts/ci-deps-relevant.mjs new file mode 100644 index 000000000000..a3cf5f581bc4 --- /dev/null +++ b/scripts/ci-deps-relevant.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// Decides whether a change can affect what a CI job measures, so dependency +// bumps that only touch unrelated tooling (tests, lint, React Native, website) +// skip it. Fails open: anything it can't classify counts as relevant. +// +// Usage: node scripts/ci-deps-relevant.mjs [,...] [path...] +// : what the job builds and runs (e.g. examples/benchmark). +// [path...]: the workflow's `paths` filter (GitHub globs, `!` excludes). +// Relevant when, between and HEAD: +// - a file matching [path...] (any file, if none given) changed, other than +// package.json and lockfiles, or +// - the root package.json changed outside devDependencies/resolutions, or +// - a listed workspace's package.json changed, or a package.json field +// other than devDependencies/version of a workspace package it depends on +// (packageManager is ignored: the yarn version doesn't change output), or +// - the yarn.lock resolutions reachable from those workspaces' dependencies +// (or from the build tooling below) changed. +// Prints the decision and writes `relevant=true|false` to $GITHUB_OUTPUT. +import { execFileSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +// Root build tooling every package build uses (babel.config.js, browserslist). +// Add new root build dependencies here: root devDependencies aren't walked. +const BUILD_TOOLS = + /^(@babel\/|core-js|browserslist$|caniuse-lite$|babel-plugin-module-resolver$|@anansi\/(babel-preset|browserslist-config)$)/; +// Type-only packages can't change built output or runtime behavior +const IGNORED = /^(@types\/|typescript$|@typescript\/)/; +const DEP_FIELDS = [ + 'dependencies', + 'peerDependencies', + 'optionalDependencies', + 'devDependencies', +]; + +const git = (...args) => + execFileSync('git', args, { + encoding: 'utf8', + maxBuffer: 1 << 28, + stdio: ['ignore', 'pipe', 'ignore'], + }); +const show = (ref, file) => { + try { + return git('show', `${ref}:${file}`); + } catch { + return undefined; + } +}; +const json = (ref, file) => { + const text = show(ref, file); + return text === undefined ? undefined : JSON.parse(text); +}; +const omit = (obj, keys) => + obj && + Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k))); +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +// '@scope/name@npm:1.0.0' -> '@scope/name' +const nameOf = descriptor => descriptor.slice(0, descriptor.indexOf('@', 1)); + +function decide(base, workspaceDirs, paths) { + git('rev-parse', '--verify', `${base}^{commit}`); + // GitHub `paths` globs as git pathspecs, minus manifests and lockfiles + const pathspecs = (paths.length ? paths : ['**']).map(p => + p.startsWith('!') ? `:(exclude,glob)${p.slice(1)}` : `:(glob)${p}`, + ); + const other = git( + 'diff', + '--name-only', + '--no-renames', + base, + 'HEAD', + '--', + ...pathspecs, + ':(exclude)yarn.lock', + ':(exclude,glob)**/package.json', + ':(exclude,glob)**/package-lock.json', + ) + .split('\n') + .filter(Boolean); + if (other.length) return `files changed: ${other.slice(0, 5).join(', ')}`; + + // Root scripts (e.g. build:benchmark); yarn.lock covers its devDependencies + // and resolutions + const root = json('HEAD', 'package.json'); + const rootOmitted = [ + 'devDependencies', + 'resolutions', + 'version', + 'packageManager', + ]; + if ( + !same( + omit(json(base, 'package.json'), rootOmitted), + omit(root, rootOmitted), + ) + ) + return 'package.json changed'; + + // Workspace packages by name, so `workspace:*` deps can be followed + const rootPatterns = root.workspaces; + const workspaces = new Map(); + for (const file of git( + 'ls-files', + '--', + ...rootPatterns.map(p => `${p}/package.json`), + ) + .split('\n') + .filter(Boolean)) { + workspaces.set(json('HEAD', file).name, file); + } + + // Seed names: everything the listed workspaces use, plus the shipped + // dependencies of workspace packages they pull in + const seeds = new Set(); + const visited = new Set(); + const queue = []; + const addDeps = (manifest, fields) => { + for (const field of fields) + for (const name of Object.keys(manifest?.[field] ?? {})) queue.push(name); + }; + for (const dir of workspaceDirs) { + const file = `${dir}/package.json`; + const head = json('HEAD', file); + if ( + !same( + omit(json(base, file), ['packageManager']), + omit(head, ['packageManager']), + ) + ) + return `${file} changed`; + addDeps(head, DEP_FIELDS); + } + while (queue.length) { + const name = queue.pop(); + if (visited.has(name) || IGNORED.test(name)) continue; + visited.add(name); + const file = workspaces.get(name); + if (!file) { + seeds.add(name); + continue; + } + const head = json('HEAD', file); + const omitted = ['devDependencies', 'version', 'packageManager']; + if (!same(omit(json(base, file), omitted), omit(head, omitted))) + return `${file} changed`; + addDeps(head, ['dependencies', 'optionalDependencies']); + } + + const before = reachable(parseLock(show(base, 'yarn.lock')), seeds); + const after = reachable(parseLock(show('HEAD', 'yarn.lock')), seeds); + const diff = [...after] + .filter(r => !before.has(r)) + .concat([...before].filter(r => !after.has(r))); + if (diff.length) + return `resolved dependencies changed: ${diff.slice(0, 5).join(', ')}`; +} + +// yarn.lock (berry) -> descriptor and name lookups for each resolution's deps +function parseLock(text) { + const byDescriptor = new Map(); + const byName = new Map(); + const deps = new Map(); + for (const block of text.split('\n\n')) { + const lines = block.split('\n'); + const header = lines.findIndex(l => /^"?[^\s#].*:$/.test(l)); + const resolution = block.match(/^ {2}resolution: "(.+)"$/m)?.[1]; + if (header < 0 || !resolution) continue; + for (const d of lines[header].slice(0, -1).split(', ')) + byDescriptor.set(d.replace(/^"|"$/g, ''), resolution); + const name = nameOf(resolution); + byName.set(name, [...(byName.get(name) ?? []), resolution]); + const own = []; + let inDeps = false; + for (const line of lines.slice(header + 1)) { + if (/^ {2}\S/.test(line)) inDeps = line === ' dependencies:'; + else if (inDeps) { + const m = line.match(/^ {4}"?([^":]+)"?: "?([^"]+)"?$/); + if (m) own.push([m[1], m[2]]); + } + } + deps.set(resolution, own); + } + return { byDescriptor, byName, deps }; +} + +function reachable({ byDescriptor, byName, deps }, seeds) { + const seen = new Set(); + const stack = []; + for (const [name, resolutions] of byName) + if (seeds.has(name) || BUILD_TOOLS.test(name)) stack.push(...resolutions); + while (stack.length) { + const res = stack.pop(); + if ( + seen.has(res) || + IGNORED.test(nameOf(res)) || + res.includes('@workspace:') + ) + continue; + seen.add(res); + for (const [name, range] of deps.get(res) ?? []) { + const target = byDescriptor.get(`${name}@${range}`); + // Unmatched ranges (patches, workspaces) follow every version of the name + stack.push(...(target ? [target] : (byName.get(name) ?? []))); + } + } + return seen; +} + +const [base, workspaces = '', ...rest] = process.argv.slice(2); +const paths = rest.filter(Boolean); +let reason; +try { + reason = decide(base, workspaces.split(',').filter(Boolean), paths); +} catch (e) { + reason = `could not decide, running anyway (${e.message.split('\n')[0]})`; +} +const relevant = reason !== undefined; +console.log( + relevant ? + `relevant: ${reason}` + : 'not relevant: no change reaches these workspaces', +); +if (process.env.GITHUB_OUTPUT) + appendFileSync(process.env.GITHUB_OUTPUT, `relevant=${relevant}\n`); diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 29166f0498f4..61a6820aa1bb 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -12,6 +12,8 @@ import versions from './versions.json'; // Keep Monaco CDN preload hashes in sync with the installed monaco-editor package. const require = createRequire(path.join(__dirname, 'package.json')); require('./scripts/generateMonacoPreloads.cjs').ensureMonacoPreloadManifest(); +// Real history for "Last updated" dates (Vercel clones shallow) +require('./scripts/deepenGitHistory.cjs').deepenGitHistory(); //const versionsRest = require('./rest_versions.json'); diff --git a/website/scripts/deepenGitHistory.cjs b/website/scripts/deepenGitHistory.cjs new file mode 100644 index 000000000000..93397ac82014 --- /dev/null +++ b/website/scripts/deepenGitHistory.cjs @@ -0,0 +1,50 @@ +'use strict'; + +/* eslint-disable no-undef */ + +/** + * Vercel builds from a ~10 commit shallow clone, so Docusaurus would date every + * page not touched in those commits to the oldest commit in the clone + * ("Last updated"). Fetches more history first. Fails open: a failed fetch + * only leaves the dates as they were. + * + * Invoked from docusaurus.config.ts. Does nothing outside Vercel builds. + */ + +const { execFileSync } = require('child_process'); + +const DEPTH = '800'; + +function deepenGitHistory() { + const { + VERCEL, + VERCEL_GIT_COMMIT_REF: ref, + VERCEL_GIT_REPO_OWNER: owner, + VERCEL_GIT_REPO_SLUG: slug, + } = process.env; + if (!VERCEL) return; + const log = msg => console.log(`deepenGitHistory: ${msg}`); + if (!ref || !owner || !slug) return log('skipped, Vercel git env missing'); + const git = args => + execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 60_000, + }).trim(); + const commits = () => git(['rev-list', '--count', 'HEAD']); + try { + if (git(['rev-parse', '--is-shallow-repository']) !== 'true') + return log(`skipped, clone not shallow (${commits()} commits)`); + const before = commits(); + // Vercel's clone has no `origin` remote, so fetch the (public) repo by URL + const url = `https://github.com/${owner}/${slug}.git`; + git(['fetch', '-q', '--no-tags', `--deepen=${DEPTH}`, url, ref]); + log(`history deepened from ${before} to ${commits()} commits`); + } catch (e) { + log( + `FAILED, last-update dates will be wrong (exit ${e.status ?? e.signal}): ${String(e.stderr || e.message).trim()}`, + ); + } +} + +module.exports = { deepenGitHistory }; diff --git a/website/scripts/vercel-ignore.sh b/website/scripts/vercel-ignore.sh index 34b493ac3f2c..b200e414f812 100755 --- a/website/scripts/vercel-ignore.sh +++ b/website/scripts/vercel-ignore.sh @@ -12,7 +12,7 @@ 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. +# Keep in sync with the `paths` of site-preview.yml. SITE_PATHS=( website docs/core docs/rest docs/graphql ':(exclude)website/CHANGELOG.md'