diff --git a/.github/workflows/enforce-single-sdk-changes.yml b/.github/workflows/enforce-single-sdk-changes.yml new file mode 100644 index 00000000000000..5a69450f99a3cd --- /dev/null +++ b/.github/workflows/enforce-single-sdk-changes.yml @@ -0,0 +1,101 @@ +name: Enforce Single SDK Changes + +on: + pull_request: + paths: + - 'docs/platforms/**' + - 'platform-includes/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check-sdk-diff-scope: + name: Check SDK diff scope + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Set up bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - name: Compute diff and evaluate + id: evaluate + run: | + set +e + SDK_LIST=$(git diff --numstat origin/${{ github.base_ref }}...HEAD | bun scripts/check-sdk-diff-scope.ts) + EXIT=$? + if [ $EXIT -eq 0 ]; then + echo "violation=false" >> "$GITHUB_OUTPUT" + elif [ -n "$SDK_LIST" ]; then + echo "violation=true" >> "$GITHUB_OUTPUT" + echo "sdk_list=$SDK_LIST" >> "$GITHUB_OUTPUT" + else + echo "error=true" >> "$GITHUB_OUTPUT" + fi + + - name: Post, update, or delete PR comment + if: steps.evaluate.outputs.error != 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const MARKER = ''; + const violation = '${{ steps.evaluate.outputs.violation }}' === 'true'; + + const {data: comments} = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(MARKER)); + + if (violation) { + const sdkList = ${{ toJSON(steps.evaluate.outputs.sdk_list) }}; + const body = `${MARKER} + ### 🚫 Non-trivial changes to multiple SDKs + + This PR contains non-trivial changes to multiple SDKs: ${sdkList}. + Changes to multiple SDKs should be submitted as separate PRs: **one PR per SDK**. + + Please **split this PR** accordingly. Thank you in advance! 🙏`; + + if (existing && existing.body !== body) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else if (!existing) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + } else if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } + + - name: Fail on unexpected error + if: steps.evaluate.outputs.error == 'true' + run: | + echo "::error::SDK diff check failed unexpectedly — the script produced no output. Check the job logs for details." + exit 1 + + - name: Fail check + if: steps.evaluate.outputs.violation == 'true' + run: exit 1 diff --git a/scripts/check-sdk-diff-scope.ts b/scripts/check-sdk-diff-scope.ts new file mode 100644 index 00000000000000..99f1795df74c4c --- /dev/null +++ b/scripts/check-sdk-diff-scope.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +import {readFileSync} from 'fs'; +/** + * Enforces the policy that a single PR should only contain non-trivial changes + * to one SDK at a time. PRs spanning multiple SDKs are harder to review and + * increase the risk of inconsistencies between platforms. + * + * Reads `git diff --numstat` from stdin (one `\t\t` line + * per changed file) and attributes each file to an SDK. If the total lines + * changed for at least MIN_VIOLATING_SDKS SDKs each exceed LINE_THRESHOLD, + * the script prints the violating SDK list and exits 1. + * + * Used in CI as: + * git diff --numstat origin/$BASE...HEAD | bun scripts/check-sdk-diff-scope.ts + */ + +/** SDKs with fewer total changed lines than this are ignored (e.g. a one-line typo fix that touches two SDKs should not block the PR). */ +const LINE_THRESHOLD = 5; +/** Minimum number of SDKs that must exceed LINE_THRESHOLD for the check to fail. */ +const MIN_VIOLATING_SDKS = 2; + +/** + * Extracts the SDK name from a changed file path, or returns null if the + * file is not attributed to any SDK. + * + * Two path patterns are recognised: + * - docs/platforms//... → sdk is the first path segment after platforms/ + * - platform-includes/.../..mdx → sdk is the first dot-segment of the filename + * e.g. `javascript.angular.mdx` → `javascript`, `react-native.mdx` → `react-native` + */ +function sdkForPath(path: string): string | null { + const segs = path.split('/'); + if (segs[0] === 'docs' && segs[1] === 'platforms') { + return segs[2] ?? null; + } + if (segs[0] === 'platform-includes') { + return segs.at(-1)!.split('.')[0]; + } + return null; +} + +// Accumulate total lines changed (added + deleted) per SDK. +const sdkLines = new Map(); + +const input = readFileSync(0, 'utf-8'); +for (const line of input.split('\n')) { + const [added, deleted, path] = line.split('\t'); + if (!path || added === '-') continue; // binary file or empty line + const sdk = sdkForPath(path.trim()); + if (sdk) { + sdkLines.set(sdk, (sdkLines.get(sdk) ?? 0) + +added + +deleted); + } +} + +// Collect SDKs that exceed the line threshold, ignoring trivial touches. +const violating = [...sdkLines.entries()] + .filter(([, n]) => n > LINE_THRESHOLD) + .map(([sdk]) => sdk) + .sort(); + +if (violating.length >= MIN_VIOLATING_SDKS) { + // Print the list for the CI step to capture and include in the PR comment. + console.log(violating.map(s => `\`${s}\``).join(', ')); + process.exit(1); +}