diff --git a/.github/workflows/ai_claude-sdk-breaking-change.yml b/.github/workflows/ai_claude-sdk-breaking-change.yml new file mode 100644 index 000000000000..2b3cbb535ae5 --- /dev/null +++ b/.github/workflows/ai_claude-sdk-breaking-change.yml @@ -0,0 +1,141 @@ +name: Claude AI SDK Breaking Change Check + +on: + pull_request: + types: [opened, synchronize] + +jobs: + # Security gate: Check if user is dotCMS organization member + # + # REQUIREMENTS FOR CLAUDE ACCESS: + # 1. Must be a member of the dotCMS organization + # 2. Membership must be set to PUBLIC visibility + # + # TROUBLESHOOTING: If blocked, visit https://github.com/orgs/dotCMS/people + # and ensure your membership is public (click "Make public" if needed) + security-check: + runs-on: ubuntu-latest + permissions: + contents: read # Allow repository checkout + # Note: Organization membership checking uses fine-grained token + # so no additional GITHUB_TOKEN permissions needed for that API + outputs: + authorized: ${{ steps.membership-check.outputs.is_member }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check organization membership + id: membership-check + uses: ./.github/actions/security/org-membership-check + with: + username: ${{ github.event.pull_request.user.login || github.actor }} + + - name: Log security decision + run: | + if [ "${{ steps.membership-check.outputs.is_member }}" = "true" ]; then + echo "โœ… Access granted: User is a dotCMS organization member" + else + echo "โŒ Access denied: User failed dotCMS organization membership check" + echo "" + echo "๐Ÿ“‹ TROUBLESHOOTING: If you are a dotCMS team member:" + echo " 1. Visit https://github.com/orgs/dotCMS/people" + echo " 2. Ensure your membership is set to 'Public'" + echo " 3. If you're not listed, contact an organization owner" + echo "" + echo "::warning::Unauthorized user attempted to trigger Claude workflow: ${{ github.event.pull_request.user.login || github.actor }}" + fi + + # Preflight: clear stale AI SDK-breaking-change labels so each push is re-evaluated from + # scratch, and skip the whole AI evaluation when the PR author has already classified the + # change via a "Human: ..." label (skip cascades to claude-sdk-breaking-change-check via + # needs/success()). + preflight-clear-stale-labels: + name: Clear stale AI SDK breaking-change labels + needs: security-check + if: | + needs.security-check.outputs.authorized == 'true' && + !contains(github.event.pull_request.labels.*.name, 'Human: SDK Breaking Change') && + !contains(github.event.pull_request.labels.*.name, 'Human: Not SDK Breaking Change') + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + steps: + - name: Remove stale AI SDK breaking-change labels so this push is re-evaluated + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + # `gh pr edit --remove-label` returns non-zero if the label is not on the PR. + # Swallow that so we don't need a pre-check fetch; any other failure is cosmetic. + for label in "AI: Not SDK Breaking Change" "AI: SDK Breaking Change"; do + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$label" || true + done + + # SDK breaking-change analysis โ€” runs on every PR push + claude-sdk-breaking-change-check: + needs: [security-check, preflight-clear-stale-labels] + # Cancel in-progress check when a new push arrives โ€” always analyze latest state + concurrency: + group: claude-sdk-breaking-${{ github.event.pull_request.number }} + cancel-in-progress: true + if: needs.security-check.outputs.authorized == 'true' + permissions: + contents: write + id-token: write + pull-requests: write + issues: write + uses: dotCMS/ai-workflows/.github/workflows/claude-orchestrator.yml@v3 + with: + model_id: ${{ vars.BEDROCK_MODEL_ID }} + bedrock_role_arn: ${{ vars.BEDROCK_ROLE_ARN }} + trigger_mode: automatic + prompt: | + You are a dotCMS SDK-compatibility analyst. Determine whether the changes in this PR + break compatibility for `@dotcms/*` SDK consumers (`@dotcms/client`, `@dotcms/react`, + `@dotcms/angular`, `@dotcms/uve`) โ€” i.e. whether an SDK version built against the + server contract *before* this change would misbehave against the server *after* this + change. + + STEP 1 โ€” Read the SDK breaking-change categories reference: + cat docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md + + STEP 2 โ€” Get the full PR diff: + git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} + + STEP 3 โ€” Analyze the diff against EVERY category in the reference document. + Focus on: GraphQL schema surface reachable via the page API's graphql.page / + graphql.content extension (new required fields/args, removed/renamed types or + fields), REST response shape changes to /api/v1/page/*, /api/v1/content, and + /api/v1/nav, changes to the UVE/editor postMessage protocol (message names or + payload shapes in DotCMSUVEAction / __DOTCMS_UVE_EVENT__), and changes to the + SdkVersionWebInterceptor / X-DotCMS-Version / X-DotCMS-Min-SDK headers or the + compareVersions() comparison contract in sdk-compatibility.ts. Ignore pure admin-UI + (dotcms-ui) changes, test-only changes, or documentation changes unless they touch + one of the above surfaces. + + STEP 4a โ€” If the changes break SDK compatibility, post this comment on the PR + using: gh pr comment ${{ github.event.pull_request.number }} --body "..." + + Format: + SDK Breaking Change Detected!!! + - Category: + - Why it breaks compatibility: + - Code that makes it breaking: + - Safer alternative (if possible): + + If multiple categories match, repeat the block for each one. + + Then add the label: gh pr edit ${{ github.event.pull_request.number }} --add-label "AI: SDK Breaking Change" + + STEP 4b โ€” If the changes do NOT break SDK compatibility: + Only add the label: gh pr edit ${{ github.event.pull_request.number }} --add-label "AI: Not SDK Breaking Change" + No comment needed. + + Be specific: quote actual file names and code lines, not generic descriptions. + claude_args: '--allowedTools "Bash(git diff*),Bash(git log*),Bash(cat docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md),Bash(gh pr comment*),Bash(gh pr edit*)"' + timeout_minutes: 15 + runner: ubuntu-latest + enable_mention_detection: false diff --git a/.github/workflows/cicd_3-trunk.yml b/.github/workflows/cicd_3-trunk.yml index 3068ed860234..f2b5d8472320 100644 --- a/.github/workflows/cicd_3-trunk.yml +++ b/.github/workflows/cicd_3-trunk.yml @@ -172,6 +172,13 @@ jobs: - name: 'Publish SDK packages to NPM (next)' id: deploy-javascript-sdk + # Notify-but-don't-fail: cicd_comp_finalize-phase.yml scans every job in the run + # via the GitHub API (not just finalize's own `needs`), so without this a + # transient npm/registry failure here would mark the whole trunk workflow red + # over an internal dev/QA publish. The failure Slack step below still fires + # (it checks the step's own outcome, not the job-level failure()/success() + # context functions, which continue-on-error would otherwise mask). + continue-on-error: true uses: ./.github/actions/core-cicd/deployment/deploy-javascript-sdk with: ref: ${{ github.sha }} @@ -195,7 +202,11 @@ jobs: slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} - name: 'Slack Notification (SDK next failure)' - if: failure() + # Checks the publish step's own outcome directly rather than the job-level + # failure()/success() context functions โ€” those reflect conclusion (which + # continue-on-error masks to "success"), not outcome, so failure() would never + # fire here once the step above has continue-on-error: true. + if: steps.deploy-javascript-sdk.outcome == 'failure' continue-on-error: true uses: ./.github/actions/core-cicd/notification/notify-slack with: @@ -203,7 +214,7 @@ jobs: payload: | > :red_circle: *SDK `next` publish FAILED!* > - > The automated SDK `next` publish failed while trying to publish version `${{ steps.next-version.outputs.version }}`. + > The automated SDK `next` publish failed while trying to publish version `${{ steps.next-version.outputs.version || 'unknown โ€” the version-compute step itself failed' }}`. > <${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|View commit> ยท <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run โ€” check logs for details> slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/cicd_6-release.yml b/.github/workflows/cicd_6-release.yml index 603e0b82bc44..0776ad7e276d 100644 --- a/.github/workflows/cicd_6-release.yml +++ b/.github/workflows/cicd_6-release.yml @@ -59,6 +59,11 @@ on: type: boolean default: true required: false + bump_min_sdk_version: + description: 'Does this release include a change that breaks SDK compatibility? If true, opens a post-release PR bumping MinSdkVersion.VALUE and notifies Slack for human review. Ignored for LTS releases.' + type: boolean + default: false + required: false java-version: description: 'Override Java version (SDKMAN format, e.g., 25.0.1-open). Compiler release auto-defaults to Java major version.' type: string @@ -100,6 +105,80 @@ jobs: exit 1 fi + # Closes the "forgot to check" gap for MinSdkVersion (see #36698): if a PR merged since + # the last standard release carries an SDK-breaking-change label but this run left + # bump_min_sdk_version false, fail loudly here rather than silently under-reporting the + # compatibility floor after the release ships. Read-only (no mutation), so it's safe to + # run before release-prepare even exists โ€” deliberately plain `gh`/bash, not the + # release-qa-status TS tool, since we only need a label check, not QA-verdict aggregation. + - name: Validate bump_min_sdk_version against merged PR labels + env: + GH_TOKEN: ${{ secrets.CI_MACHINE_TOKEN || github.token }} + REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + BUMP_MIN_SDK_VERSION: ${{ github.event.inputs.bump_min_sdk_version }} + run: | + set -euo pipefail + + # Only standard (non-LTS) releases carry the -## suffix; LTS patch releases don't + # participate in the @latest SDK contract, so skip entirely. + if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]{2}\.[0-9]{2}\.[0-9]{2}-[0-9]{1,2}$ ]]; then + echo "Non-standard release version '${RELEASE_VERSION}' (LTS or custom) โ€” skipping SDK breaking-change validation." + exit 0 + fi + + THIS_TAG="v${RELEASE_VERSION}" + + # Resolve the previous standard release tag the same way release-qa-status does + # (same STANDARD_RELEASE_PATTERN, kept in sync on purpose). + PREV_TAG=$(gh api "repos/${REPO}/releases?per_page=100" --paginate \ + --jq '[.[] | select(.tag_name | test("^v[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}-[0-9]{1,2}$"))] | .[].tag_name' \ + | grep -v -F -- "${THIS_TAG}" | sort -V | tail -1 || true) + + if [ -z "${PREV_TAG}" ]; then + echo "::warning::No previous standard release tag found โ€” skipping SDK breaking-change validation (nothing to compare against)." + exit 0 + fi + + echo "Comparing ${PREV_TAG}...main for merged PRs since the last release." + + # release-prepare hasn't created this release's tag yet at this point in the + # pipeline โ€” main is the correct proxy for "what's about to ship" (release_commit + # defaults to main's tip the same way). + PR_NUMBERS=$(gh api "repos/${REPO}/compare/${PREV_TAG}...main" --paginate \ + --jq '.commits[].commit.message' \ + | grep -oE '\(#[0-9]+\)$' | grep -oE '[0-9]+' | sort -un || true) + + BREAKING_PRS=() + for pr in ${PR_NUMBERS}; do + LABELS=$(gh pr view "${pr}" --repo "${REPO}" --json labels --jq '.labels[].name' 2>/dev/null || true) + if echo "${LABELS}" | grep -qxE 'AI: SDK Breaking Change|Human: SDK Breaking Change'; then + BREAKING_PRS+=("#${pr}") + fi + done + + if [ "${#BREAKING_PRS[@]}" -gt 0 ] && [ "${BUMP_MIN_SDK_VERSION}" != "true" ]; then + echo "::error::This release (${THIS_TAG}) includes PR(s) labeled as SDK-breaking (${BREAKING_PRS[*]}) since ${PREV_TAG}, but 'bump_min_sdk_version' was left false. Re-run with bump_min_sdk_version=true, or confirm none of these PRs actually break SDK compatibility and override their label to 'Human: Not SDK Breaking Change' before re-running." + exit 1 + fi + + if [ "${#BREAKING_PRS[@]}" -eq 0 ] && [ "${BUMP_MIN_SDK_VERSION}" = "true" ]; then + echo "::warning::bump_min_sdk_version=true but no merged PR since ${PREV_TAG} carries an SDK-breaking label. Proceeding, since a human explicitly opted in โ€” but double-check this is intentional." + fi + + # Non-fatal nudge: a stale, unmerged bump PR from a previous release won't be caught + # by this run's own dedupe (different branch name, keyed on this release's version). + STALE_PR=$(gh pr list --repo "${REPO}" --head "sdk/bump-min-sdk-version-" --base main --state open --json url --jq '.[0].url' 2>/dev/null || true) + if [ -z "${STALE_PR}" ]; then + STALE_PR=$(gh api "repos/${REPO}/pulls?state=open&base=main" --paginate \ + --jq '.[] | select(.head.ref | startswith("sdk/bump-min-sdk-version-")) | .html_url' | head -1 || true) + fi + if [ -n "${STALE_PR}" ]; then + echo "::warning::An unmerged MinSdkVersion bump PR from a previous release is still open: ${STALE_PR}. Consider merging it before this release ships another one." + fi + + echo "SDK breaking-change validation passed (${#BREAKING_PRS[@]} breaking PR(s) found, bump_min_sdk_version=${BUMP_MIN_SDK_VERSION})." + # Initialize - standard initialization phase (always first) initialize: name: Initialize @@ -170,10 +249,11 @@ jobs: # Promote latest - move the floating `latest` Docker tag to this GA release. # - # evergreen-tracks (cicd/evergreen-tracks) is the single controller of the - # latest/standard/trailing tags. Its daily cron ages standard/trailing, but - # `latest` must move the instant a GA ships โ€” so the release pipeline invokes - # the same engine on-demand, scoped to the latest track only (--tracks latest). + # evergreen-tracks (.github/actions/core-cicd/evergreen-tracks) is the single controller of the + # latest/standard/trailing tags. standard/trailing move only on a manual + # evergreen-tracks-promote dispatch, but `latest` must move the instant a GA + # ships โ€” so the release pipeline invokes the same engine on-demand, scoped to + # the latest track only (--tracks latest). # This replaces the old deploy-docker `latest: true` path (now `latest: false`). # # Runs for every GA release from main on dotcms/core by default โ€” moving latest @@ -198,7 +278,9 @@ jobs: permissions: contents: read # Share the registry-mutation lock with the promote/admin workflows so this - # on-demand promote can never race the daily cron or an admin run. + # on-demand promote can never race a manual promote apply or an admin run. + # The promote workflow holds this lock only in its post-approval apply job, + # so a promotion pending human approval never queues this job. concurrency: group: evergreen-tracks-registry cancel-in-progress: false @@ -212,13 +294,126 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Move latest to ${{ needs.release-prepare.outputs.release_version }} - working-directory: cicd/evergreen-tracks + working-directory: .github/actions/core-cicd/evergreen-tracks run: | for repo in dotcms/dotcms dotcms/dotcms-dev; do echo "--- promoting latest on ${repo} ---" uv run evergreen-tracks promote --repo "${repo}" --tracks latest --apply done + # Bump MinSdkVersion.java on main โ€” only after a full green release, and only when the + # operator explicitly flagged this release as SDK-compatibility-breaking (see #36698). + # Never a direct push to main: opens a PR and asks a human to merge it (main has no + # auto-merge and requires a PR for any change, confirmed via the retired + # cicd_manual-release-sdks.yml's identical "Open post-release PR" precedent). + # + # A sibling job here (not a step in the generic, reusable cicd_comp_release-phase.yml) + # so this dotCMS-core-specific concern doesn't leak into a phase other callers reuse. + bump-min-sdk-version: + name: Bump MinSdkVersion.java + needs: [ release-prepare, build, deployment ] + # Strict success() gate (not the always()-based idiom used by build/deployment/release): + # a release that fails after release-prepare must NEVER cause main to advertise a + # stricter MIN_SDK_VERSION for a dotCMS version that was never actually shipped. + if: >- + success() + && github.event.inputs.bump_min_sdk_version == 'true' + && github.ref_name == 'main' + && needs.release-prepare.outputs.is_lts != 'true' + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout core + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.CI_MACHINE_TOKEN || github.token }} + + - name: Bump MinSdkVersion.VALUE and open PR + id: bump + env: + GH_TOKEN: ${{ secrets.CI_MACHINE_TOKEN || github.token }} + CI_MACHINE_USER: ${{ secrets.CI_MACHINE_USER || 'github-actions[bot]' }} + RELEASE_VERSION: ${{ needs.release-prepare.outputs.release_version }} + run: | + set -euo pipefail + git config user.name "${CI_MACHINE_USER}" + git config user.email "${CI_MACHINE_USER}@users.noreply.github.com" + + FILE="dotCMS/src/main/java/com/dotcms/rest/config/MinSdkVersion.java" + PR_BRANCH="sdk/bump-min-sdk-version-${RELEASE_VERSION}" + + # Idempotency (a): main is already at this version โ€” nothing to do. Covers both a + # true no-op release and a retried workflow whose bump PR already merged. + CURRENT=$(grep -oP '(?<=VALUE = ")[^"]+' "${FILE}") + if [ "${CURRENT}" = "${RELEASE_VERSION}" ]; then + echo "MinSdkVersion.VALUE is already ${RELEASE_VERSION} on main โ€” nothing to bump." + echo "skipped=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + git fetch origin main + git checkout -B "${PR_BRANCH}" origin/main + + sed -i "s/VALUE = \"[^\"]*\"/VALUE = \"${RELEASE_VERSION}\"/" "${FILE}" + git add "${FILE}" + + if git diff --cached --quiet; then + echo "No diff after edit โ€” nothing to commit." + else + git commit -m "chore(sdk): bump MinSdkVersion.VALUE to ${RELEASE_VERSION}" + fi + + git push --force origin "${PR_BRANCH}" + + # Idempotency (b): reuse an already-open PR for this exact version/branch instead + # of opening a duplicate on a retried run. + EXISTING_PR=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${PR_BRANCH}" --base main --json url --jq '.[0].url' 2>/dev/null || true) + if [ -n "${EXISTING_PR}" ]; then + echo "PR already exists: ${EXISTING_PR}" + PR_URL="${EXISTING_PR}" + else + PR_URL=$(gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --title "chore(sdk): bump MinSdkVersion.VALUE to ${RELEASE_VERSION}" \ + --body "Automated post-release bump. Release \`${RELEASE_VERSION}\` was flagged (via \`bump_min_sdk_version\`) as containing an SDK-breaking change. This raises the compatibility floor advertised via the \`X-DotCMS-Min-SDK\` header to \`${RELEASE_VERSION}\`. Please review and merge โ€” this does not auto-merge." \ + --base main \ + --head "${PR_BRANCH}") + fi + echo "pr_url=${PR_URL}" >> "${GITHUB_OUTPUT}" + echo "skipped=false" >> "${GITHUB_OUTPUT}" + + - name: Slack Notification (bump PR ready for review) + if: success() && steps.bump.outputs.skipped == 'false' + continue-on-error: true + uses: ./.github/actions/core-cicd/notification/notify-slack + with: + channel-id: "log-sdk-libs" + payload: | + > :large_orange_circle: *Attention dotters:* dotCMS `${{ needs.release-prepare.outputs.release_version }}` was released with an SDK-breaking change. + > + > A PR bumping `MinSdkVersion.VALUE` to `${{ needs.release-prepare.outputs.release_version }}` is open on `main`. + > *Please review and merge the post-release PR ASAP:* <${{ steps.bump.outputs.pr_url }}|View PR> + > <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run> + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + + - name: Slack Notification (bump PR failure) + if: failure() + continue-on-error: true + uses: ./.github/actions/core-cicd/notification/notify-slack + with: + channel-id: "log-sdk-libs" + payload: | + > :red_circle: *MinSdkVersion bump FAILED!* + > + > Release `${{ needs.release-prepare.outputs.release_version }}` succeeded, but opening the post-release PR to bump `MinSdkVersion.VALUE` failed. + > *Triggered by:* `${{ github.actor }}` + > <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run โ€” check logs for details> + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + # Release - release-specific operations (Artifactory, Javadocs, Plugins, SBOM, Labels) # Waits for deployment to complete to safely update labels only if both succeed release: @@ -261,7 +456,7 @@ jobs: finalize: name: Finalize if: always() - needs: [ initialize, build, deployment, release ] + needs: [ initialize, build, deployment, release, bump-min-sdk-version ] uses: ./.github/workflows/cicd_comp_finalize-phase.yml with: artifact-run-id: ${{ github.run_id }} diff --git a/core-web/libs/sdk/client/jest.config.ts b/core-web/libs/sdk/client/jest.config.ts index 99dde95d0ac1..81207b827b8b 100644 --- a/core-web/libs/sdk/client/jest.config.ts +++ b/core-web/libs/sdk/client/jest.config.ts @@ -10,5 +10,11 @@ export default { ] }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../../coverage/libs/sdk/client' + coverageDirectory: '../../../coverage/libs/sdk/client', + moduleNameMapper: { + // 'virtual:sdk-version' only exists as a rollup-generated virtual module + // (see sdkVersionPlugin in rollup.config.cjs) โ€” ts-jest never runs the rollup + // build, so point it at a real stub file instead. + '^virtual:sdk-version$': '/src/lib/utils/__mocks__/virtual-sdk-version.ts' + } }; diff --git a/core-web/libs/sdk/client/rollup.config.cjs b/core-web/libs/sdk/client/rollup.config.cjs index dbb675127c13..1c67ff9bf4d1 100644 --- a/core-web/libs/sdk/client/rollup.config.cjs +++ b/core-web/libs/sdk/client/rollup.config.cjs @@ -1,3 +1,6 @@ +const fs = require('fs'); +const path = require('path'); + const { withNx } = require('@nx/rollup/with-nx'); // These options were migrated by @nx/rollup:convert-to-inferred from project.json @@ -18,10 +21,47 @@ const options = { tsConfig: './tsconfig.lib.json' }; +// Injects this package's own version (already set to the exact dotCMS release version +// by the deploy-javascript-sdk release pipeline before this build runs โ€” see +// .github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml) as a build-time +// constant, so @dotcms/client can compare itself against the X-DotCMS-Version / +// X-DotCMS-Min-SDK response headers at runtime (see lib/utils/sdk-compatibility.ts). +// +// Implemented as a plain Rollup virtual-module plugin (no @rollup/plugin-replace +// dependency needed) โ€” withNx() concatenates any `plugins` passed in its second +// argument onto its own generated plugin list, so this doesn't replace Nx's plugins. +function sdkVersionPlugin() { + const virtualModuleId = 'virtual:sdk-version'; + const resolvedVirtualModuleId = '\0' + virtualModuleId; + + return { + name: 'sdk-version', + resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + + return null; + }, + load(id) { + if (id !== resolvedVirtualModuleId) { + return null; + } + + const pkg = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8') + ); + + return `export const SDK_VERSION = ${JSON.stringify(pkg.version)};`; + } + }; +} + const config = withNx(options, { // Provide additional rollup configuration here. See: https://rollupjs.org/configuration-options // e.g. // output: { sourcemap: true }, + plugins: [sdkVersionPlugin()] }); module.exports = config; diff --git a/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.spec.ts b/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.spec.ts index f641a60fa31d..66ba61cd7fa2 100644 --- a/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.spec.ts +++ b/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.spec.ts @@ -2,9 +2,15 @@ import { DotHttpError } from '@dotcms/types'; import { FetchHttpClient } from './fetch-http-client'; +import { checkSdkCompatibility } from '../../utils/sdk-compatibility'; + // Mock fetch globally global.fetch = jest.fn(); +jest.mock('../../utils/sdk-compatibility', () => ({ + checkSdkCompatibility: jest.fn() +})); + describe('FetchHttpClient', () => { let httpClient: FetchHttpClient; let mockFetch: jest.MockedFunction; @@ -13,6 +19,28 @@ describe('FetchHttpClient', () => { httpClient = new FetchHttpClient(); mockFetch = fetch as jest.MockedFunction; mockFetch.mockClear(); + (checkSdkCompatibility as jest.Mock).mockClear(); + }); + + describe('SDK compatibility check', () => { + it('calls checkSdkCompatibility with the response headers and the SDK version', async () => { + const mockHeaders = new Headers({ + 'content-type': 'application/json', + 'x-dotcms-version': '26.7.13', + 'x-dotcms-min-sdk': '26.5.1' + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: mockHeaders, + json: jest.fn().mockResolvedValue({ data: 'test' }) + } as unknown as Response); + + await httpClient.request('https://api.example.com/test'); + + expect(checkSdkCompatibility).toHaveBeenCalledTimes(1); + expect(checkSdkCompatibility).toHaveBeenCalledWith(mockHeaders, '0.0.0-test'); + }); }); describe('request', () => { diff --git a/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.ts b/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.ts index 2b612e9a689e..a9912ecfae47 100644 --- a/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.ts +++ b/core-web/libs/sdk/client/src/lib/client/adapters/fetch-http-client.ts @@ -1,5 +1,10 @@ +import { SDK_VERSION } from 'virtual:sdk-version'; + import { BaseHttpClient, DotRequestOptions } from '@dotcms/types'; +import { checkSdkCompatibility } from '../../utils/sdk-compatibility'; +// Build-time constant โ€” see sdkVersionPlugin in rollup.config.cjs. + /** * HTTP client implementation using the Fetch API. * @@ -68,6 +73,12 @@ export class FetchHttpClient extends BaseHttpClient { // Use native fetch API - no additional configuration needed const response = await fetch(url, options); + // Fire-and-forget: reads X-DotCMS-Version / X-DotCMS-Min-SDK off the + // response and logs a console warning on mismatch. Fails open (no headers, + // e.g. an older server) and never throws, so this can't affect the actual + // request/response handling below. + checkSdkCompatibility(response.headers, SDK_VERSION); + if (!response.ok) { // Parse response body for error context let errorBody: string | unknown; diff --git a/core-web/libs/sdk/client/src/lib/utils/__mocks__/virtual-sdk-version.ts b/core-web/libs/sdk/client/src/lib/utils/__mocks__/virtual-sdk-version.ts new file mode 100644 index 000000000000..2fe8a1e50432 --- /dev/null +++ b/core-web/libs/sdk/client/src/lib/utils/__mocks__/virtual-sdk-version.ts @@ -0,0 +1,4 @@ +// Jest stub for the `virtual:sdk-version` module that rollup generates at build time +// (see `sdkVersionPlugin` in rollup.config.cjs). Mapped in jest.config.ts's +// moduleNameMapper since ts-jest never runs the rollup build. +export const SDK_VERSION = '0.0.0-test'; diff --git a/core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.spec.ts b/core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.spec.ts new file mode 100644 index 000000000000..a032d55c68ef --- /dev/null +++ b/core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.spec.ts @@ -0,0 +1,116 @@ +import { + checkSdkCompatibility, + compareVersions, + resetSdkCompatibilityWarnings +} from './sdk-compatibility'; + +describe('compareVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareVersions('26.7.13', '26.7.13')).toBe(0); + }); + + it('compares numerically, not as strings', () => { + expect(compareVersions('26.10.1', '26.7.13')).toBe(1); + expect(compareVersions('26.7.13', '26.10.1')).toBe(-1); + }); + + it('compares the counter/prerelease segment too', () => { + expect(compareVersions('26.7.13-2', '26.7.13-1')).toBe(1); + expect(compareVersions('26.7.13-1', '26.7.13-2')).toBe(-1); + }); + + it('treats a missing trailing segment as 0', () => { + expect(compareVersions('26.7.13', '26.7.13-0')).toBe(0); + }); + + it('returns null for unparsable versions instead of throwing', () => { + expect(compareVersions('26.7.13_lts_v1', '26.7.13')).toBeNull(); + expect(compareVersions('not-a-version', '26.7.13')).toBeNull(); + }); +}); + +describe('checkSdkCompatibility', () => { + let errorSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + resetSdkCompatibilityWarnings(); + errorSpy = jest.spyOn(console, 'error').mockImplementation(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('does nothing when the headers are absent (older server)', () => { + checkSdkCompatibility(new Headers(), '26.7.13'); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when versions are compatible', () => { + const headers = new Headers({ + 'x-dotcms-version': '26.7.13', + 'x-dotcms-min-sdk': '26.5.1' + }); + + checkSdkCompatibility(headers, '26.7.13'); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('logs a console error when the SDK is older than the minimum supported version', () => { + const headers = new Headers({ + 'x-dotcms-version': '26.7.13', + 'x-dotcms-min-sdk': '26.5.1' + }); + + checkSdkCompatibility(headers, '26.1.1'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toContain('26.1.1'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('logs a console warning when the SDK is newer than the server', () => { + const headers = new Headers({ + 'x-dotcms-version': '26.7.13', + 'x-dotcms-min-sdk': '26.5.1' + }); + + checkSdkCompatibility(headers, '26.10.1'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('26.10.1'); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('only logs each warning once per session, across multiple calls', () => { + const headers = new Headers({ + 'x-dotcms-version': '26.7.13', + 'x-dotcms-min-sdk': '26.5.1' + }); + + checkSdkCompatibility(headers, '26.1.1'); + checkSdkCompatibility(headers, '26.1.1'); + checkSdkCompatibility(headers, '26.1.1'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('fails open (does nothing) when own version is unparsable/empty', () => { + const headers = new Headers({ + 'x-dotcms-version': '26.7.13', + 'x-dotcms-min-sdk': '26.5.1' + }); + + checkSdkCompatibility(headers, ''); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.ts b/core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.ts new file mode 100644 index 000000000000..8a6a40424e86 --- /dev/null +++ b/core-web/libs/sdk/client/src/lib/utils/sdk-compatibility.ts @@ -0,0 +1,106 @@ +const DOTCMS_VERSION_HEADER = 'x-dotcms-version'; +const DOTCMS_MIN_SDK_HEADER = 'x-dotcms-min-sdk'; + +let hasWarnedOutdatedSdk = false; +let hasWarnedNewerSdk = false; + +/** + * Parses a date-lockstep version string (e.g. "26.7.14-1") into a flat array of numeric + * segments for ordered comparison. Returns null if any segment isn't a plain integer + * (e.g. an LTS-shaped version like "26.7.14_lts_v1"), so callers can skip the comparison + * instead of comparing unrelated formats. + */ +const parseVersionSegments = (version: string): number[] | null => { + const segments = version + .trim() + .replace(/^v/i, '') + .split(/[.-]/) + .map((segment) => Number(segment)); + + return segments.some((segment) => !Number.isFinite(segment)) ? null : segments; +}; + +/** + * Compares two date-lockstep version strings segment by segment as numbers (not as + * strings), so e.g. "26.10.1" correctly compares greater than "26.7.13". Returns null + * (instead of throwing) if either version can't be parsed, so callers can fail open. + */ +export const compareVersions = (a: string, b: string): number | null => { + const segmentsA = parseVersionSegments(a); + const segmentsB = parseVersionSegments(b); + + if (!segmentsA || !segmentsB) { + return null; + } + + const length = Math.max(segmentsA.length, segmentsB.length); + + for (let i = 0; i < length; i++) { + const partA = segmentsA[i] ?? 0; + const partB = segmentsB[i] ?? 0; + + if (partA !== partB) { + return partA > partB ? 1 : -1; + } + } + + return 0; +}; + +/** + * Resets the once-per-session warning flags. Test-only โ€” production code never needs + * to warn more than once per page load. + */ +export const resetSdkCompatibilityWarnings = (): void => { + hasWarnedOutdatedSdk = false; + hasWarnedNewerSdk = false; +}; + +/** + * Reads the dotCMS server's advertised version and minimum supported SDK version off a + * response (see `SdkVersionWebInterceptor` on the server) and logs a console error/warning + * if this SDK is outside the compatible range: + * + * - `ownVersion < X-DotCMS-Min-SDK` โ†’ console.error, the SDK must be upgraded. + * - `ownVersion > X-DotCMS-Version` โ†’ console.warn, the SDK is ahead of this server and + * may call APIs it doesn't have yet (e.g. a dev environment newer than the one it's + * pointed at). + * + * Fails open by design: if the headers are absent (an older server that doesn't send + * them yet) or unparsable, this silently does nothing โ€” it never throws and never + * changes request/response behavior. Each kind of warning logs at most once per session + * so it doesn't spam the console on every request. + */ +export const checkSdkCompatibility = (headers: Headers, ownVersion: string): void => { + try { + if (!ownVersion) { + return; + } + + const serverVersion = headers.get(DOTCMS_VERSION_HEADER); + const minSdkVersion = headers.get(DOTCMS_MIN_SDK_HEADER); + + if (!serverVersion || !minSdkVersion) { + return; + } + + if (!hasWarnedOutdatedSdk && (compareVersions(ownVersion, minSdkVersion) ?? 0) < 0) { + hasWarnedOutdatedSdk = true; + console.error( + `[dotCMS SDK] SDK ${ownVersion} is not supported by dotCMS ${serverVersion} ` + + `(minimum required: ${minSdkVersion}). Upgrade required: ` + + 'https://www.dotcms.com/docs/latest/sdk-version-compatibility' + ); + } + + if (!hasWarnedNewerSdk && (compareVersions(ownVersion, serverVersion) ?? 0) > 0) { + hasWarnedNewerSdk = true; + console.warn( + `[dotCMS SDK] SDK ${ownVersion} is newer than dotCMS ${serverVersion} ` + + 'and may call APIs the server does not have yet.' + ); + } + } catch { + // Never let a compatibility-check failure break the actual request. + } +}; diff --git a/core-web/libs/sdk/client/src/virtual-modules.d.ts b/core-web/libs/sdk/client/src/virtual-modules.d.ts new file mode 100644 index 000000000000..0a4c79f24caf --- /dev/null +++ b/core-web/libs/sdk/client/src/virtual-modules.d.ts @@ -0,0 +1,12 @@ +/** + * `virtual:sdk-version` is not a real file โ€” it's generated at build time by the + * `sdkVersionPlugin` rollup plugin in `rollup.config.cjs`, which reads this package's own + * `package.json` version (already set to the exact dotCMS release version by the + * deploy-javascript-sdk release pipeline before the build runs). + * + * Unit tests never go through rollup, so `jest.config.ts` maps this module id to a real + * stub file (`src/lib/utils/__mocks__/virtual-sdk-version.ts`) instead. + */ +declare module 'virtual:sdk-version' { + export const SDK_VERSION: string; +} diff --git a/docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md b/docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md new file mode 100644 index 000000000000..23e34449fd01 --- /dev/null +++ b/docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md @@ -0,0 +1,301 @@ +# SDK Breaking Change Categories โ€” Developer Reference + +>**Purpose:** Help developers (and the automated PR checker) decide whether a change breaks +>compatibility with already-published `@dotcms/client`, `@dotcms/react`, `@dotcms/angular`, and +>`@dotcms/uve` SDK versions โ€” i.e. whether `MinSdkVersion.VALUE` needs to move. +> +>**Rule of thumb:** a change is SDK-breaking if an SDK built against the server contract *before* +>this change would misbehave (throw, silently drop data, or misinterpret a message) against the +>server *after* this change โ€” with no fallback path in the SDK for the old shape. + +Unlike [`ROLLBACK_UNSAFE_CATEGORIES.md`](ROLLBACK_UNSAFE_CATEGORIES.md), this reference doesn't use a +CRITICAL/HIGH/MEDIUM/LOW risk scale โ€” this doc answers a single binary question for a single +purpose (does `MinSdkVersion.VALUE` need to move), not a graded operational-risk assessment. Each +category below is either **Breaking** or **Conditionally breaking** (breaks only if a specific +additional condition holds, called out explicitly). + +--- + +## Quick Reference โ€” Decision Card + +``` +Is my change a... + +Removed/renamed GraphQL type or field reachable โ†’ Breaking (G-1) + via graphql.page / graphql.content? +New REQUIRED argument on an existing GraphQL field/query โ†’ Breaking (G-2) + used by the page API's query builder? +Changed GraphQL structured-error extensions.code semantics โ†’ Breaking (G-3) + (NOT_FOUND, PERMISSION_DENIED) that page-api.ts branches on? +Renamed/removed JSON field in /api/v1/nav or โ†’ Breaking (R-1) + /api/v1/content response shapes the SDK types model? +Removed or renamed an inbound postMessage name the SDK โ†’ Breaking (U-1) + listens for (__DOTCMS_UVE_EVENT__)? +Changed the payload shape of an outbound postMessage the โ†’ Breaking (U-2) + editor consumes (DotCMSUVEAction) without a compat shape? +Changed X-DotCMS-Version / X-DotCMS-Min-SDK header names, โ†’ Breaking (H-1) + casing assumptions, or the version-comparison contract? + +New OPTIONAL GraphQL field/query, additive REST field, โ†’ โœ… Not breaking + new postMessage type old SDKs simply never send/receive, + internal refactor with no wire-format change, admin-UI + (dotcms-ui) only change +``` + +--- + +# G โ€” GraphQL Page/Content API Surface + +The Page API (`core-web/libs/sdk/client/src/lib/client/page/page-api.ts`) builds and sends GraphQL +queries via `buildPageQuery`/`buildQuery`, and the Content API's collection builder does the same for +`XCollection(...)`-style queries. Both are SDK-authored queries sent to the server's GraphQL schema. + +## G-1 โ€” Removing or Renaming a Reachable GraphQL Type/Field + +**Direction:** Breaking + +### Context + +SDK consumers write GraphQL fragments referencing schema types/fields directly in their own code +(see `PageClient.get()`'s `graphql.page` / `graphql.content` / `graphql.fragments` parameters). These +fragments are compiled into the request the SDK sends โ€” the SDK itself doesn't know the schema +shape ahead of time; it trusts the server contract. + +### Why it breaks compatibility + +If a field or type an already-deployed customer application's fragment references is removed or +renamed, the customer's next request fails GraphQL query validation entirely โ€” `response.data` comes +back `null` (see `page-api.ts`'s "BAD QUERY" branch), and the whole page load throws a `DotErrorPage`. +There's no partial degradation; the entire query fails. + +### Signals to watch for in code review + +- A field or type removed from a GraphQL schema definition that's part of the public content/page + surface (not an internal-only type) +- A field renamed without a `@deprecated`-and-kept-working transition period + +### Safer alternative + +- Add the new field/type alongside the old one; deprecate the old one for at least one release cycle + before removing it +- If a rename is unavoidable, keep the old field as an alias resolving to the same value + +--- + +## G-2 โ€” New Required Argument on an Existing GraphQL Field/Query + +**Direction:** Breaking + +### Context + +`buildPageQuery`/`buildQuery` in `page-api.ts` construct queries with a fixed, SDK-known set of +arguments (e.g. the page query's `url`, `languageId`, `mode`, `personaId`, etc., built from +`DotCMSPageRequestParams`). + +### Why it breaks compatibility + +If the server adds a new **required** (non-nullable, no default) argument to a field the SDK already +queries, every request from an SDK built before that change omits it โ€” GraphQL query validation +rejects the request outright. + +### Signals to watch for in code review + +- A new argument added to an existing GraphQL field/query definition with no default value +- Any change to the page or content query root that isn't purely additive-and-optional + +### Safer alternative + +- New arguments must be optional with a server-side default that preserves today's behavior + +--- + +## G-3 โ€” Structured GraphQL Error Contract Change + +**Direction:** Breaking + +### Context + +`page-api.ts` branches explicitly on `error.extensions?.code` (`NOT_FOUND` โ†’ 404 + specific message, +`PERMISSION_DENIED` โ†’ 403 + specific message, anything else โ†’ generic 400) to build a typed +`DotErrorPage`. This is a contract the SDK actively parses, not just logs. + +### Why it breaks compatibility + +If the server stops setting `extensions.code`, renames the code strings, or changes when they're +emitted, the SDK's error classification silently falls through to the generic/wrong branch โ€” a +"page not found" could get misreported as a generic 400, breaking any consumer code that branches on +`DotErrorPage`'s `status`/`code`. + +### Signals to watch for in code review + +- Changes to how GraphQL resolvers set `extensions.code` on errors +- Renaming or removing the `NOT_FOUND` / `PERMISSION_DENIED` extension codes + +### Safer alternative + +- Treat `extensions.code` values as a versioned public contract โ€” add new codes freely, never rename + or remove existing ones + +--- + +# R โ€” REST Response Shape Changes + +## R-1 โ€” REST Response Field Removed or Renamed + +**Direction:** Breaking + +### Context + +`NavigationClient.get()` (`navigation-api.ts`) calls `GET {dotcmsUrl}/api/v1/nav{path}` and reads the +response as `{ entity: DotCMSNavigationItem[] }` directly off `response.entity` โ€” no defensive +parsing. The Content API's collection builder similarly expects a fixed response envelope. + +### Why it breaks compatibility + +A renamed or removed field in the REST response (e.g. `entity` renamed, or a `DotCMSNavigationItem` +field like `href`/`title` renamed) means the SDK either reads `undefined` silently (TypeScript types +lie about runtime shape) or the whole navigation tree fails to render, with no clear error since +there's no schema validation at the boundary. + +### Signals to watch for in code review + +- Renaming a JSON field in `/api/v1/nav`, `/api/v1/content`, or `/api/v1/page/*` responses +- Changing the response envelope structure (e.g. `entity` wrapper removed or restructured) + +### Safer alternative + +- Add new fields additively; never rename or remove a field already returned in these endpoints + without a deprecation window +- Prefer `@JsonProperty` aliasing on the Java side so both old and new field names resolve + +--- + +# U โ€” UVE/Editor `postMessage` Protocol + +The Universal Visual Editor communicates with the SDK via `window.postMessage` in both directions. +Inbound (editor โ†’ SDK) messages are dispatched by name via `__DOTCMS_UVE_EVENT__` constants and +consumed in `core-web/libs/sdk/uve/src/internal/events.ts` (e.g. `onContentChanges`, `onPageReload`, +`onAutoBounds`, `onIframeScroll`, `onScrollToSection`, `onContentletClicked` โ€” matching message names +`UVE_SET_PAGE_DATA`, `UVE_RELOAD_PAGE`, `UVE_FLUSH_BOUNDS`, `UVE_SCROLL_INSIDE_IFRAME`, +`UVE_SCROLL_TO_SECTION`, `UVE_SELECTION_CLEARED`). Outbound (SDK โ†’ editor) messages are named via the +`DotCMSUVEAction` enum in `core-web/libs/sdk/types/src/lib/editor/public.ts` (e.g. `set-url`, +`set-bounds`, `set-contentlet`, `set-selected-contentlet`, `scroll`). + +## U-1 โ€” Inbound Message Name or Payload Removed/Renamed + +**Direction:** Breaking + +### Context + +An old SDK's `events.ts` listeners match on an exact `event.data.name` string. There is no version +negotiation in the protocol itself โ€” it's a bare string match. + +### Why it breaks compatibility + +If the editor stops sending a message name an already-deployed SDK listens for (or renames it, or +changes the payload shape a listener destructures), that SDK's corresponding feature goes silently +dead โ€” no error, the callback just never fires (e.g. `onAutoBounds`'s drag-flush channel, or +`onScrollToSection`'s section-jump handling). + +### Signals to watch for in code review + +- A message name constant in `__DOTCMS_UVE_EVENT__` removed or renamed +- A payload shape change for an existing message (e.g. `event.data.sectionIndex` renamed or + restructured in `onScrollToSection`) + +### Safer alternative + +- Add new message names/payloads alongside old ones; dual-emit both shapes for at least one release + cycle before removing the old one + +--- + +## U-2 โ€” Outbound Message Payload Shape Change + +**Direction:** Breaking + +### Context + +The editor's own listeners parse `DotCMSUVEAction` payloads sent by the SDK (e.g. `set-bounds`, +`set-selected-contentlet`). An older editor session (cached admin UI, or a customer running an +older dotCMS version against a newer SDK in a mixed-version scenario) expects the payload shape it +was built against. + +### Why it breaks compatibility + +If the SDK-side payload shape changes (fields renamed/removed on the object sent via +`window.parent.postMessage`) without the editor also updating in lockstep, the editor either +misreads the payload or ignores it โ€” selection overlays, bounds, or hover state stop updating +correctly with no visible error. + +### Signals to watch for in code review + +- A payload shape change on any `DotCMSUVEAction` message (`set-bounds`, `set-contentlet`, + `set-selected-contentlet`, `set-url`, `scroll`, etc.) +- Renaming a `DotCMSUVEAction` enum value's string (the wire value, not just the enum key) + +### Safer alternative + +- Keep the wire-level string values of `DotCMSUVEAction` stable even if the TS enum key changes +- Add new fields to a payload additively; never remove/rename a field an existing editor build reads + +--- + +# H โ€” SDK Compatibility Headers Themselves + +## H-1 โ€” Compatibility Handshake Mechanism Change + +**Direction:** Breaking (most severe category โ€” breaks the detection mechanism itself) + +### Context + +`SdkVersionWebInterceptor` sets `X-DotCMS-Version` (from `ReleaseInfo.getVersion()`) and +`X-DotCMS-Min-SDK` (from `MinSdkVersion.VALUE`) on every response. `sdk-compatibility.ts`'s +`checkSdkCompatibility()` reads them case-insensitively via `Headers.get()`, and `compareVersions()` +parses both as numeric, dot/dash-separated segments (`parseVersionSegments`), returning `null` (fail +open, no warning) for anything that doesn't parse as plain integers โ€” including LTS-shaped strings +like `26.7.14_lts_v1`. + +### Why it breaks compatibility + +This is the mechanism this entire document exists to protect. If a change alters the header names, +their casing-sensitivity assumptions, or the version-string shape/comparison semantics +`compareVersions()` depends on (e.g. switching away from date-lockstep numeric segments to something +`parseVersionSegments` can't parse), the compatibility check silently stops working for every SDK +version at once โ€” not just for one release's floor value. + +### Signals to watch for in code review + +- Any change to `X-DotCMS-Version` / `X-DotCMS-Min-SDK` header names in `SdkVersionWebInterceptor` +- Any change to `compareVersions()` / `parseVersionSegments()` in `sdk-compatibility.ts` +- Bumping `MinSdkVersion.VALUE` to a non-numeric-segment (e.g. LTS-shaped) string โ€” this doesn't just + fail to gate correctly, it silently disables the check entirely for that comparison + +### Safer alternative + +- Treat this mechanism itself as doubly-reviewed: any change here should be treated as breaking by + default unless proven otherwise, since a bug here is invisible (fails open, no warning, no error) + +--- + +## Non-Breaking Examples (for calibration) + +- Admin UI (`dotcms-ui`) only changes โ€” not consumed by any SDK +- Adding a new **optional** GraphQL field/query (existing SDK queries simply don't request it) +- Adding a new REST response field (additive โ€” old SDK code ignores fields it doesn't read) +- Adding a new inbound/outbound `postMessage` type that old SDKs simply never send/receive +- Internal refactors with no change to any wire format (GraphQL schema, REST JSON shape, or + `postMessage` payload) +- Test-only or documentation-only changes + +--- + +## A Note on This Document's Maturity + +Unlike `ROLLBACK_UNSAFE_CATEGORIES.md` (grounded in years of real dotCMS incident history), this +document starts with no real-world track record of an actual SDK-breaking release. Categories above +are derived from reading the current SDK source, not from a postmortem. Treat the automated AI check +built on this document as an aid to human review, not a substitute for it, until it accumulates a +track record โ€” the same posture the rollback-safety check already takes via its `Human: ...` +override labels. Expect this document to gain real "Examples from dotCMS history" entries over time +as actual breaking changes occur and get retroactively categorized here. diff --git a/dotCMS/src/main/java/com/dotcms/filters/interceptor/meta/SdkVersionWebInterceptor.java b/dotCMS/src/main/java/com/dotcms/filters/interceptor/meta/SdkVersionWebInterceptor.java new file mode 100644 index 000000000000..a9ba0d873067 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/filters/interceptor/meta/SdkVersionWebInterceptor.java @@ -0,0 +1,62 @@ +package com.dotcms.filters.interceptor.meta; + +import com.dotcms.filters.interceptor.Result; +import com.dotcms.filters.interceptor.WebInterceptor; +import com.dotcms.rest.config.MinSdkVersion; +import com.dotmarketing.util.Logger; +import com.liferay.portal.util.ReleaseInfo; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Advertises the running dotCMS version and the oldest SDK version it still supports on + * every response, via two static headers: + * + *
    + *
  • {@code X-DotCMS-Version} โ€” this server's version ({@link ReleaseInfo#getVersion()}).
  • + *
  • {@code X-DotCMS-Min-SDK} โ€” the oldest {@code @dotcms/*} SDK version this server + * still supports ({@link MinSdkVersion#VALUE}).
  • + *
+ * + *

The {@code @dotcms/client} SDK reads these headers off responses it already makes to + * warn when the installed SDK is older than what the server requires, or newer than the + * server itself. + * + *

Implemented as a {@link WebInterceptor} (registered in + * {@link com.dotmarketing.filters.InterceptorFilter}) rather than a JAX-RS + * {@code ContainerResponseFilter}: a JAX-RS filter only covers requests routed through + * Jersey, but SDK traffic also hits plain-servlet endpoints outside JAX-RS entirely โ€” e.g. + * GraphQL ({@code /api/v1/graphql}, served by {@code DotGraphQLHttpServlet}, a raw + * {@code HttpServlet}, not a JAX-RS resource). {@code InterceptorFilter} is the first filter + * in the pipeline and maps every request, so this covers both uniformly โ€” the same reason + * {@link ResponseMetaDataWebInterceptor} (the {@code x-dot-server} header) uses this same + * mechanism instead of a JAX-RS filter. + * + *

Unrelated to CORS request handling on the default configuration โ€” {@code + * Access-Control-Expose-Headers} defaults to {@code *} (see {@code + * dotmarketing-config.properties}), so these two headers are exposed cross-origin out of + * the box. Note this default can be narrowed per-resource (e.g. an {@code + * api.cors.graphql.Access-Control-Expose-Headers} override) โ€” an environment that does so + * would silently stop exposing these headers to browser-side SDK calls on that resource. + */ +public class SdkVersionWebInterceptor implements WebInterceptor { + + public static final String DOTCMS_VERSION_HEADER = "X-DotCMS-Version"; + public static final String DOTCMS_MIN_SDK_HEADER = "X-DotCMS-Min-SDK"; + + @Override + public Result intercept(final HttpServletRequest request, final HttpServletResponse response) { + try { + response.setHeader(DOTCMS_VERSION_HEADER, ReleaseInfo.getVersion()); + response.setHeader(DOTCMS_MIN_SDK_HEADER, MinSdkVersion.VALUE); + } catch (Exception e) { + // Never let a header-advertisement failure break the actual request, but log it + // so a systemic failure of the SDK compatibility handshake is discoverable. + Logger.debug(this, "Unable to set SDK compatibility headers: " + e.getMessage(), e); + } + + return Result.NEXT; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/MinSdkVersion.java b/dotCMS/src/main/java/com/dotcms/rest/config/MinSdkVersion.java new file mode 100644 index 000000000000..c43f0d7d55ce --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/config/MinSdkVersion.java @@ -0,0 +1,50 @@ +package com.dotcms.rest.config; + +/** + * The oldest {@code @dotcms/*} SDK version this dotCMS instance still supports, exposed + * to clients via the {@code X-DotCMS-Min-SDK} response header (see + * {@link com.dotcms.filters.interceptor.meta.SdkVersionWebInterceptor}). + * + *

This value is maintained via a human-reviewed automated PR, not by hand. Under + * date-lockstep SDK versioning (ADR-0019: {@code platform-adrs/decisions/0019-sdk-cms-date-lockstep-versioning.md}) + * most dotCMS releases never change the SDK contract, so this constant does not need to + * move on every release โ€” only bump it when a change actually breaks compatibility with + * older {@code @dotcms/*} SDK versions. See {@code docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md} + * for concrete categories (removed/renamed GraphQL fields, changed {@code postMessage} + * editor protocol messages, REST response shape changes, etc.). + * + *

Bump procedure (automated): do not edit {@link #VALUE} directly in a + * PR. Instead: + *

    + *
  1. Get your PR labeled {@code AI: SDK Breaking Change} โ€” an automated check + * ({@code ai_claude-sdk-breaking-change.yml}) evaluates every PR's diff against + * {@code docs/core/SDK_BREAKING_CHANGE_CATEGORIES.md}. If you disagree with its + * verdict, apply {@code Human: SDK Breaking Change} or + * {@code Human: Not SDK Breaking Change} yourself to override it.
  2. + *
  3. At release time, the operator running {@code cicd_6-release.yml} sets its + * {@code bump_min_sdk_version} input to {@code true} for a release that includes + * such a PR. The release's {@code verify-branch} job fails outright if a + * breaking-change-labeled PR merged since the last release is in range and this + * input was left {@code false} โ€” forgetting is a loud pipeline failure, not a + * silent gap.
  4. + *
  5. Once the release fully succeeds (build AND deployment green โ€” never eagerly), + * a dedicated job opens a PR against {@code main} bumping {@link #VALUE} to that + * release's version and pings Slack asking a human to review and merge it. + * {@code main} is never pushed to directly, and nothing changes on {@code main} if + * the release fails partway through โ€” there is nothing to roll back.
  6. + *
+ */ +public final class MinSdkVersion { + + /** + * No breaking change has been introduced under this mechanism yet, so every + * previously published SDK version is still considered compatible. The first real + * bump of this value should replace this baseline. + */ + public static final String VALUE = "0.0.0"; + + private MinSdkVersion() { + // utility class, no instances + } + +} diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java index 700a3cdbf4ba..4b43833c056c 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java @@ -6,6 +6,7 @@ import com.dotcms.filters.interceptor.AbstractWebInterceptorSupportFilter; import com.dotcms.filters.interceptor.WebInterceptorDelegate; import com.dotcms.filters.interceptor.meta.ResponseMetaDataWebInterceptor; +import com.dotcms.filters.interceptor.meta.SdkVersionWebInterceptor; import com.dotcms.graphql.GraphqlCacheWebInterceptor; import com.dotcms.jitsu.EventLogWebInterceptor; import com.dotcms.prerender.PreRenderSEOWebInterceptor; @@ -51,6 +52,11 @@ private void addInterceptors(final FilterConfig config) { delegate.add(new MultiPartRequestSecurityWebInterceptor()); delegate.add(new PreRenderSEOWebInterceptor()); delegate.add(new EMAWebInterceptor()); + // Must run before GraphqlCacheWebInterceptor: on a GraphQL cache hit that + // interceptor writes the response and returns Result.SKIP_NO_CHAIN, breaking the + // delegate chain before any later interceptor runs. Registering this one first + // means its headers are already on the response by the time that happens. + delegate.add(new SdkVersionWebInterceptor()); delegate.add(new GraphqlCacheWebInterceptor()); delegate.add(new ResponseMetaDataWebInterceptor()); delegate.add(new EventLogWebInterceptor());