diff --git a/.github/workflows/check-package-changelogs.yml b/.github/workflows/check-package-changelogs.yml index f7b384bfabdab0..816543d8596449 100644 --- a/.github/workflows/check-package-changelogs.yml +++ b/.github/workflows/check-package-changelogs.yml @@ -1,4 +1,4 @@ -name: OPTIONAL - Verify package CHANGELOG updates +name: Verify package CHANGELOG updates on: pull_request: @@ -22,17 +22,21 @@ on: - 'packages/widget-dashboard/**' - 'packages/widget-primitives/**' - 'packages/wp-build/**' + - 'packages/*/CHANGELOG.md' + - 'tools/validation/validate-changelogs.ts' + - '.github/workflows/check-package-changelogs.yml' - '!packages/*/src/**/stories/**' - '!packages/*/src/**/test/**' - - '!packages/*/CHANGELOG.md' # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. permissions: {} jobs: - check: - name: Check CHANGELOG diff (${{ matrix.package }}) + # Presence: The allowlisted packages listed in the matrix have stricter + # expectations around changes having an accompanying changelog note. + presence: + name: Check CHANGELOG presence (${{ matrix.package }}) runs-on: 'ubuntu-24.04' permissions: contents: read @@ -87,12 +91,25 @@ jobs: | grep -Ev "^${PACKAGE_PATH}/(src/(.+/)?(stories|test)/|CHANGELOG\.md$)" || true) if [ -z "$relevant_changes" ]; then - echo "No relevant changes under ${PACKAGE_PATH}; skipping CHANGELOG check." + echo "No relevant changes under ${PACKAGE_PATH}; skipping CHANGELOG presence check." echo "has_changes=false" >> "$GITHUB_OUTPUT" else echo "has_changes=true" >> "$GITHUB_OUTPUT" fi - - name: Check CHANGELOG status + # This intentionally doesn't use `.github/setup-node`, because we + # don't currently need to `npm ci` install any dependencies. + - name: Use desired version of Node.js + if: steps.filter.outputs.has_changes == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + - name: Set up npm + if: steps.filter.outputs.has_changes == 'true' + uses: ./.github/setup-npm + with: + cache: false + - name: Check CHANGELOG cites this PR if: steps.filter.outputs.has_changes == 'true' env: PR_NUMBER: ${{ github.event.number }} @@ -104,30 +121,60 @@ jobs: # Fail if the PR doesn't touch the changelog if git diff --quiet "$BASE_SHA"...HEAD -- "$changelog_path"; then - echo "Please add a CHANGELOG entry to $changelog_path" - echo - echo "${optional_check_notice}" - exit 1 - fi - - pr_link_pattern="\[#${PR_NUMBER}\]\(https://github\.com/WordPress/gutenberg/pull/${PR_NUMBER}\)" - pr_link_grep_pattern="\[#${PR_NUMBER}\](https://github\.com/WordPress/gutenberg/pull/${PR_NUMBER})" - - unreleased_section=$(sed -n '/^## Unreleased$/,/^## /p' "${changelog_path}") - - # Confirm that the CHANGELOG has an entry for the current PR - if ! grep -nq -e "${pr_link_grep_pattern}" "${changelog_path}"; then - echo "Please add a CHANGELOG entry to $changelog_path, and make sure your CHANGELOG entry has a link to the current PR." + echo "Please add a CHANGELOG entry to $changelog_path" echo echo "${optional_check_notice}" exit 1 fi - # Confirm that there is an 'Unreleased' section and that the relevant entry is in that section - if ! grep -nq -e '^## Unreleased' "${changelog_path}" || \ - ! [[ $unreleased_section = *${pr_link_pattern}* ]]; then - echo "Please make sure your CHANGELOG entry is in the \`## Unreleased\` section" - echo - echo "${optional_check_notice}" - exit 1 + # Confirm that the CHANGELOG has a valid entry for the current PR + npm run lint:changelogs -- --require-pr="${PR_NUMBER}" "${changelog_path}" + + # Structure: For any revisions to CHANGELOGs (regardless of package), + # validate that the contents follow the expected format. + structure: + name: Validate CHANGELOG structure + runs-on: 'ubuntu-24.04' + permissions: + contents: read + steps: + - name: 'Get PR commit count' + env: + PR_COUNT: ${{ github.event.pull_request.commits }} + run: echo "PR_COMMIT_COUNT=$(( PR_COUNT + 1 ))" >> "${GITHUB_ENV}" + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: ${{ env.PR_COMMIT_COUNT }} + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + - name: 'Fetch relevant history from base repository' + run: git fetch "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$GITHUB_BASE_REF" + # This intentionally doesn't use `.github/setup-node`, because we + # don't currently need to `npm ci` install any dependencies. + - name: Use desired version of Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + - name: Set up npm + uses: ./.github/setup-npm + with: + cache: false + - name: Validate touched CHANGELOG files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_NUMBER: ${{ github.event.number }} + run: | + changelogs=$(git diff --name-only --diff-filter=d "$BASE_SHA"...HEAD -- 'packages/*/CHANGELOG.md') + + if [ -z "$changelogs" ]; then + echo "No package CHANGELOG.md files changed; skipping structure check." + exit 0 fi + + echo "Validating:" + echo "$changelogs" + echo "$changelogs" | xargs npm run lint:changelogs -- --pr="${PR_NUMBER}" diff --git a/AGENTS.md b/AGENTS.md index 77b3b08721e04a..ebcc2cbb5b5114 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ For full architecture details, see `docs/explanations/architecture/`. - Pages and routes are stable by default. Mark experimental pages with `"experimental": true` in `wpPlugin.pages`; WordPress Core builds exclude those pages and routes used only by experimental pages. Do not introduce separate `stable` or `core` flags for this distinction. - Never invoke a CLI through `npx`. When the binary is missing locally, `npx` installs whatever the public registry serves under that name: WordPress ships its own `wp-prettier` fork, and a bare `npx wp-scripts` can resolve to an unrelated third-party package rather than `@wordpress/scripts`. Use the npm scripts (`npm run format`, `npm run lint:js`, `npm run lint:css` and so on), which run the binaries from local `node_modules`. Where no script fits, use `npm exec --no -- ` (adding `--workspace ` when the workspace that declares the dependency is not the one you are in), which fails instead of downloading. A bare `npm exec` behaves exactly like `npx`, so it needs `--no` too. The exception is a doc example handing the reader a tool they have not installed, such as `npx @wordpress/create-block@latest`: it names an exact package rather than a hijackable bin name. - PHP function and class names are renamed at build time (`gutenberg_*` prefix, `*_Gutenberg` suffix) to avoid conflicts with WordPress Core — the built names, not the source names, are what runs (and what tests must call). See `docs/contributors/code/build-system-function-prefixing.md`. -- Code changes in a package that have an external impact to consumers should be accompanied by an entry in that package's `CHANGELOG.md`. Entries in the changelog should have a PR reference. In some cases you may not know the PR reference, ask the user. See `docs/contributors/code/managing-packages.md`. +- Code changes in a package that have an external impact to consumers should be accompanied by an entry in that package's `CHANGELOG.md`. Follow the formatting structure described in [Maintaining Changelogs](packages/README.md#maintaining-changelogs). - Packages with TypeScript dev files split their configs: `tsconfig.build.json` (src only, emits `build-types`) and the default `tsconfig.json` (dev project: tests and stories, `noEmit`, jest types). Packages without dev files keep a single `tsconfig.json` build project, and a few packages deviate (a dev-only project with handwritten declarations, or specialized build projects); see the TypeScript section in `packages/README.md`. Reference a split package by `..//tsconfig.build.json` and an unsplit one by `../`. `npm run build` never type checks (it emits declarations with `--noCheck`); use `npm run typecheck`, and never add jest types to a build project. - A rejected `apiFetch` is not always an `Error`: a REST error arrives as a plain object (`{ code, message, data }`), `parse: false` rejects with the `Response` (which carries `status`, not `message`), an aborted request rethrows an `AbortError`, and a handler set via `setFetchHandler` can reject anything. Do not interpolate the rejection into a string (`` `${ error }` `` gives `[object Object]`) or branch on `instanceof Error`. Normalise it to a message before showing the user anything, and supply your own copy when there is none — `ensureError` in `packages/core-data/src/private-actions.js` is the reference implementation, though it is local to that file rather than exported. diff --git a/package-lock.json b/package-lock.json index c5b07e036c7919..493f50c4a8fa7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48662,6 +48662,7 @@ }, "devDependencies": { "@mawesome/dependency-audit": "^0.4.6", + "@types/node": "^24.13.3", "@wordpress/create-block": "file:../../packages/create-block", "@wordpress/monorepo-tools": "file:../monorepo", "@wordpress/scripts": "file:../../packages/scripts", diff --git a/package.json b/package.json index 94f21711519ab5..1384dfb82d0135 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "lint:lockfile": "npm run --workspace @wordpress/validation-tools validate-package-lock --", "lint:package-contents": "npm run --if-present --workspaces lint:package-contents", "lint:tsconfig": "npm run --workspace @wordpress/validation-tools validate-tsconfig --", + "lint:changelogs": "npm run --workspace @wordpress/validation-tools validate-changelogs --", "lint:md:docs": "wp-scripts lint-md-docs", "prelint:php": "npm run other:update-packages:php", "lint:php": "wp-env run --env-cwd='wp-content/plugins/gutenberg' cli composer run-script lint", diff --git a/packages/README.md b/packages/README.md index 6f0c899e504f8f..947f52c9f8b1ff 100644 --- a/packages/README.md +++ b/packages/README.md @@ -268,11 +268,15 @@ See [Testing published packages across WordPress versions](/docs/contributors/co ## Maintaining Changelogs -When maintaining dozens of npm packages, it can be tough to keep track of changes. To simplify the release process, each package includes a `CHANGELOG.md` file which details all published releases and the unreleased ("Unreleased") changes, if any exist. +Each package keeps a `CHANGELOG.md` so the release process can see what changed since the last publish. Put new entries under `## Unreleased` at the top of the file, or create the heading if it is missing. -For each pull request, you should always include relevant changes under an "Unreleased" heading at the top of the file. You should add the heading if it doesn't already exist. +Prefer an entry for anything that affects package consumers. Changelog entries are optional for trivial or for changes that don't impact the user or consumer. Internal changes can be added under an **Internal** heading. -_Example:_ +### Changelog Entry Format + +Under the "Unreleased" heading, add entries as list items under an appropriate `###` subheading (see ["Changelog Subsections"](#changelog-subsections)). Each top-level bullet should end with a link to the pull request and a period. + +Example: ```md @@ -281,16 +285,16 @@ _Example:_ ### Bug Fixes -- Fixed an off-by-one error with the `sum` function. +- Fixed an off-by-one error with the `sum` function ([#12347](https://github.com/WordPress/gutenberg/pull/12347)). ``` +You can verify the structure locally by running `npm run lint:changelogs`. + ### Promoting a Pre-Release Package to Stable (1.0.0) The automated package publishing workflow will at most bump the minor version of a pre-release package (those having a version like `0.x.x`), even if it includes breaking changes. This is consistent with semantic versioning, where `0.x` versions are intended for initial development where the API may change frequently. -When a package's API is considered stable and ready for production use, it should be promoted to version 1.0.0. This is done by adding a "Stable Release" section to the `CHANGELOG.md` file: - -_Example:_ +A `0.x` package is promoted to `1.0.0` by adding a **Stable Release** section. ```md ## Unreleased @@ -301,7 +305,7 @@ This package is now considered stable and production-ready. The API will follow ### Breaking Changes -- Final API adjustments before 1.0.0 release. +- Final API adjustments before 1.0.0 release ([#12345](https://github.com/WordPress/gutenberg/pull/12345)). ``` The presence of the "Stable Release" heading will cause the automated release process to bump a pre-1.0 package to 1.0.0. The "Stable Release" heading should only be used for pre-1.0 packages, and from that point forward breaking changes will result in major version bumps as expected. @@ -310,17 +314,16 @@ The presence of the "Stable Release" heading will cause the automated release pr There are a number of common release subsections you can follow. Each is intended to align to a specific meaning in the context of the [Semantic Versioning (`semver`) specification](https://semver.org/) the project adheres to. It is important that you describe your changes accurately, since this is used in the packages release process to help determine the version of the next release. +Use the following standardized section headings and ordering based on the type of change you are making: + +- "Stable Release" - Marks a pre-1.0 package as stable and production-ready. This should only be used for packages currently published as a 0.x pre-release, to intentionally communicate that a package's API is now stable and ready for production use. - "Breaking Changes" - A backwards-incompatible change which requires specific attention of the impacted developers to reconcile (requires a major version bump for stable packages). - "New Features" - The addition of a new backwards-compatible function or feature to the existing public API (requires a minor version bump). - "Enhancements" - Backwards-compatible improvements to existing functionality (requires a minor version bump). - "Deprecations" - Deprecation notices. These do not impact the public interface or behavior of the module (requires a minor version bump). - "Bug Fixes" - Resolutions to existing buggy behavior (requires a patch version bump). - "Internal" - Changes which do not have an impact on the public interface or behavior of the module (requires a patch version bump). -- "Stable Release" - Marks a pre-1.0 package as stable and production-ready. This should only be used for packages currently published as a 0.x pre-release, to intentionally communicate that a package's API is now stable and ready for production use. - -While other section naming can be used when appropriate, it's important that are expressed clearly to avoid confusion for both the packages releaser and third-party consumers. - -When in doubt, refer to [Semantic Versioning specification](https://semver.org/). +- "Documentation" - Changes affecting consumer documentation. If you are publishing new versions of packages, note that there are versioning recommendations outlined in the [Gutenberg Release Process document](https://github.com/WordPress/gutenberg/blob/HEAD/docs/contributors/code/release/README.md) which prescribe _minimum_ version bumps for specific types of releases. The chosen version should be the greater of the two between the semantic versioning and Gutenberg release minimum version bumps. diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index bd9528f5a6f17e..29f621e5eeba7c 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,10 +2,6 @@ ## Unreleased -### Internal - -- Remove the `crossorigin` MutationObserver. Under `Document-Isolation-Policy: isolate-and-credentialless` cross-origin resources load without the attribute, and adding it broke any resource served without CORS headers ([#82614](https://github.com/WordPress/gutenberg/pull/82614)). - ### New Features - Add a text shadow block support and its control in the typography panel ([#79584](https://github.com/WordPress/gutenberg/pull/79584)). @@ -27,6 +23,7 @@ ### Internal +- Remove the `crossorigin` MutationObserver. Under `Document-Isolation-Policy: isolate-and-credentialless` cross-origin resources load without the attribute, and adding it broke any resource served without CORS headers ([#82614](https://github.com/WordPress/gutenberg/pull/82614)). - Block bindings and variation transforms: Use the public `Menu` from `@wordpress/ui` instead of the private Components API. ([#81925](https://github.com/WordPress/gutenberg/pull/81925)) - Layout hooks: Use `normalizeLegacyLayout` in `useLayoutClasses`, `useLayoutStyles`, the block layout styles wrapper and `isAxialBlockGapAllowed`, replacing four inline copies of the legacy `inherit` / size check ([#82710](https://github.com/WordPress/gutenberg/pull/82710)). - Extract the populated pattern category computation from `usePatternCategories` into a `getPopulatedCategories` util, and expose it and `searchItems` through private APIs for reuse by the start page options modal ([#81396](https://github.com/WordPress/gutenberg/pull/81396)). diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 37fe84a484e6fc..3a0a79443c6f0c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -19,6 +19,10 @@ - `Autocomplete`: Show focus ring with the design system's outline instead of previous box-shadow implementation ([#82766](https://github.com/WordPress/gutenberg/pull/82766)). - `CustomGradientPicker`: Show the control point's focus ring with the design system's outline and a contrasting backing to separate it from the gradient ([#82834](https://github.com/WordPress/gutenberg/pull/82834)). +### Deprecations + +- `Divider`: Deprecate the component, planned for removal in WordPress 7.4 ([#83040](https://github.com/WordPress/gutenberg/pull/83040)). + ### Bug Fixes - `Card`: Keep shadow radii aligned with the Card's actual radius regardless of render order ([#82572](https://github.com/WordPress/gutenberg/pull/82572)). @@ -26,10 +30,6 @@ - `BorderBoxControl`: Restore the split borders layout. The top and bottom controls are centred across the row and the right control sits at the end of its column again, after `BorderControl`'s own `margin: 0` began overriding both ([#82939](https://github.com/WordPress/gutenberg/pull/82939)). - `PaletteEdit`: Separate adjacent header action buttons so their focus rings do not overlap ([#82768](https://github.com/WordPress/gutenberg/pull/82768)). -### Deprecations - -- `Divider`: Deprecate the component, planned for removal in WordPress 7.4 ([#83040](https://github.com/WordPress/gutenberg/pull/83040)). - ### Internal - Remove obsolete Jest test dependencies and types ([#82975](https://github.com/WordPress/gutenberg/pull/82975)). diff --git a/packages/connectors/CHANGELOG.md b/packages/connectors/CHANGELOG.md index c1a6d109013ecc..9347c5eff3c366 100644 --- a/packages/connectors/CHANGELOG.md +++ b/packages/connectors/CHANGELOG.md @@ -2,14 +2,11 @@ ## Unreleased -### Code Quality - -- Add missing `@types/react` dependency. [#78882](https://github.com/WordPress/gutenberg/pull/78882). - ### Enhancements - Widen React peer dependency ranges to `^18 || ^19` to support both React 18 and React 19 environments ([#80024](https://github.com/WordPress/gutenberg/pull/80024)). ### Internal +- Add missing `@types/react` dependency. [#78882](https://github.com/WordPress/gutenberg/pull/78882). - Split tsconfig into a build project and a default dev project so dev files are type checked without publishing their declarations. ([#81514](https://github.com/WordPress/gutenberg/pull/81514)) diff --git a/packages/dashboard-init/CHANGELOG.md b/packages/dashboard-init/CHANGELOG.md index 481d96810ecd52..094988fbdb4375 100644 --- a/packages/dashboard-init/CHANGELOG.md +++ b/packages/dashboard-init/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### New Features + +- Initial version of the package ([#79394](https://github.com/WordPress/gutenberg/pull/79394)). + +### Internal + - Register the dashboard's widget icon resolver at init, before the page renders ([#80969](https://github.com/WordPress/gutenberg/pull/80969)). -- Initial version of the package. diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 33eae2b923b90a..47424489f1bd87 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -7,10 +7,6 @@ - Grid layout: when the title is hidden (`showTitle: false`) and items are clickable, label each item's clickable media area with its title instead of the generic "Navigate to item" ([#82639](https://github.com/WordPress/gutenberg/pull/82639)). - DataForm: Hide the edit button of disabled fields in the panel layout, as it already is for read-only fields ([#82957](https://github.com/WordPress/gutenberg/pull/82957)). -### Docs - -- Add entry for `isDisabled` in README and clarify difference from `readOnly` fields. [#82956](https://github.com/WordPress/gutenberg/pull/82956) - ### Internal - Remove obsolete Jest test dependencies and types ([#82975](https://github.com/WordPress/gutenberg/pull/82975)). @@ -19,6 +15,10 @@ - Run rendered DataViews tests in Vitest Browser Mode ([#80995](https://github.com/WordPress/gutenberg/pull/80995)). - DataForm panel layout: Replace deprecated `word-break: break-word` with `word-break: normal` and `overflow-wrap: anywhere` on field summary controls. ([#82776](https://github.com/WordPress/gutenberg/pull/82776)). +### Documentation + +- Add entry for `isDisabled` in README and clarify difference from `readOnly` fields ([#82956](https://github.com/WordPress/gutenberg/pull/82956)). + ## 19.0.0 (2026-09-10) ### Breaking Changes diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 6d21fe8f971a46..6cce1f7b108b42 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -19,12 +19,9 @@ - Document bar: Preserve the subdued template-preview icon color after the icon became stroke-based. ([#82540](https://github.com/WordPress/gutenberg/pull/82540)) - `useGlobalStylesOutput`: Let the styles engine derive block gap support from the config it renders, instead of a separate check that treated a theme without block gap support as supporting it and so dropped the fallback gap styles the front end renders ([#82401](https://github.com/WordPress/gutenberg/pull/82401)). -### Code Quality - -- Post template panel: Remove the dead template read capability check. ([#82861](https://github.com/WordPress/gutenberg/pull/82861)) - ### Internal +- Post template panel: Remove the dead template read capability check. ([#82861](https://github.com/WordPress/gutenberg/pull/82861)) - Notes: Use the public `@wordpress/ui` Menu for note actions instead of the private Components API ([#81930](https://github.com/WordPress/gutenberg/pull/81930)). - Post actions: Use the public `Menu` from `@wordpress/ui` instead of the private Components API ([#81922](https://github.com/WordPress/gutenberg/pull/81922)). diff --git a/packages/report-flaky-tests/CHANGELOG.md b/packages/report-flaky-tests/CHANGELOG.md index 8f592cda0051fb..1f4ea9f81b5eee 100644 --- a/packages/report-flaky-tests/CHANGELOG.md +++ b/packages/report-flaky-tests/CHANGELOG.md @@ -6,12 +6,10 @@ - Increase the minimum required Node.js version to 24.18.0 and npm version to 11.16.0 ([#82663](https://github.com/WordPress/gutenberg/pull/82663)). - Flaky tests are no longer reported to GitHub issues, and are no longer reported on pushes. The action renders the detected tests and their errors as markdown to the required `output-path` input, and `tools/pr-meta` posts it as one section of the single automation comment on the pull request. The `label` and `repo-token` inputs have been removed as a result ([#82249](https://github.com/WordPress/gutenberg/pull/82249)). -- The action now runs straight from its TypeScript sources on Node's type stripping, instead of from a bundle. Relative imports must carry their `.ts` extension, and the syntax must be erasable — no enums, namespaces, parameter properties or decorators. +- The action now runs straight from its TypeScript sources on Node's type stripping, instead of from a bundle. Relative imports must carry their `.ts` extension, and the syntax must be erasable — no enums, namespaces, parameter properties or decorators ([#81218](https://github.com/WordPress/gutenberg/pull/81218)). ### Internal - Update `@actions/core` to `^3.0.1` ([#82663](https://github.com/WordPress/gutenberg/pull/82663)). - Remove the unused `@actions/github` and `@octokit/webhooks-types` dependencies ([#82249](https://github.com/WordPress/gutenberg/pull/82249)). - Split tsconfig into a build project and a default dev project so dev files are type checked without publishing their declarations. ([#81517](https://github.com/WordPress/gutenberg/pull/81517)) - --- Initial version of the package. diff --git a/packages/rich-text/CHANGELOG.md b/packages/rich-text/CHANGELOG.md index 62be2aadf11b06..b8dca5f834b3ec 100644 --- a/packages/rich-text/CHANGELOG.md +++ b/packages/rich-text/CHANGELOG.md @@ -4,7 +4,7 @@ ### Bug Fixes -- `useRichText`: apply every selection change from props, including the second of two consecutive ones, and only while the element or an editing host around it has focus. The hook no longer moves focus to the element; the consumer decides where focus goes. +- `useRichText`: apply every selection change from props, including the second of two consecutive ones, and only while the element or an editing host around it has focus. The hook no longer moves focus to the element; the consumer decides where focus goes ([#82581](https://github.com/WordPress/gutenberg/pull/82581)). ## 7.55.0 (2026-09-10) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 45796d4cfa034b..add0facb8392c8 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -28,9 +28,6 @@ - `Autocomplete`: Use valid grid and rowgroup semantics without local accessibility overrides ([#82835](https://github.com/WordPress/gutenberg/pull/82835)). - `Menu`: Center labels, descriptions, suffixes, shortcuts, and submenu chevrons within items, including items with taller prefix or suffix content. ([#82847](https://github.com/WordPress/gutenberg/pull/82847)) - `Menu`: vertically center single-line item labels while preserving first-line alignment for wrapped content. ([#81921](https://github.com/WordPress/gutenberg/pull/81921)) - -### Bug Fixes - - `Autocomplete`, `Combobox`, `Menu`, and `Select`: Restore the highlighted item indicator in forced-colors mode, which wp-admin's global CSS was suppressing. ([#82772](https://github.com/WordPress/gutenberg/pull/82772)) ### Internal diff --git a/tools/validation/package.json b/tools/validation/package.json index 2129d0b17f9845..40f9a4d3b03d7a 100644 --- a/tools/validation/package.json +++ b/tools/validation/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@mawesome/dependency-audit": "^0.4.6", + "@types/node": "^24.13.3", "@wordpress/create-block": "file:../../packages/create-block", "@wordpress/monorepo-tools": "file:../monorepo", "@wordpress/scripts": "file:../../packages/scripts", @@ -48,6 +49,7 @@ "test-create-block": "node ./test-create-block.mjs", "validate-package-lock": "node ./validate-package-lock.cjs", "validate-tsconfig": "node ./validate-tsconfig.mjs", + "validate-changelogs": "node ./validate-changelogs.ts", "test-vitest-consumers": "node ./test-vitest-consumers.mjs" } } diff --git a/tools/validation/tsconfig.json b/tools/validation/tsconfig.json new file mode 100644 index 00000000000000..574832e1704d33 --- /dev/null +++ b/tools/validation/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "@wordpress/monorepo-tools/tsconfig/node.base.json", + "include": [ "*.ts" ] +} diff --git a/tools/validation/validate-changelogs.test.ts b/tools/validation/validate-changelogs.test.ts new file mode 100644 index 00000000000000..27cc8e8a491d45 --- /dev/null +++ b/tools/validation/validate-changelogs.test.ts @@ -0,0 +1,249 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; +import { + parseArgs, + resolveChangelogPath, + validateChangelog, +} from './validate-changelogs.ts'; + +const REPO_ROOT = resolve( + dirname( fileURLToPath( import.meta.url ) ), + '../..' +); + +const HEADER = + '\n\n'; + +function changelog( unreleasedBody: string ): string { + return ( + HEADER + + '## Unreleased\n\n' + + unreleasedBody + + '\n## 1.0.0 (2026-01-01)\n' + ); +} + +describe( 'validateChangelog', () => { + test( 'accepts a well-formed Unreleased section', () => { + const errors = validateChangelog( + changelog( `### Enhancements + +- Improved something ([#12345](https://github.com/WordPress/gutenberg/pull/12345)). + +### Bug Fixes + +- Fixed something ([#12346](https://github.com/WordPress/gutenberg/pull/12346)). +` ), + { filePath: 'packages/example/CHANGELOG.md' } + ); + expect( errors ).toEqual( [] ); + } ); + + test( 'accepts nested list items when a child carries the PR link', () => { + const errors = validateChangelog( + changelog( `### Breaking Changes + +- Parent description: + - \`Child\` ([#82570](https://github.com/WordPress/gutenberg/pull/82570)) +` ) + ); + expect( errors ).toEqual( [] ); + } ); + + test( 'accepts a nested PR link after a blank line under the parent bullet', () => { + const errors = validateChangelog( + changelog( `### Breaking Changes + +- Parent description: + + - \`Child\` ([#82570](https://github.com/WordPress/gutenberg/pull/82570)) +` ) + ); + expect( errors ).toEqual( [] ); + } ); + + test( 'accepts a PR link on a soft-wrapped continuation line', () => { + const errors = validateChangelog( + changelog( `### Bug Fixes + +- Fixed something that wraps onto the next line + ([#82570](https://github.com/WordPress/gutenberg/pull/82570)). +` ) + ); + expect( errors ).toEqual( [] ); + } ); + + test( 'rejects headings other than ### inside Unreleased', () => { + const errors = validateChangelog( + changelog( `## Enhancements + +- Optimized listeners ([#82842](https://github.com/WordPress/gutenberg/pull/82842)). + +#### Notes + +- Extra detail ([#82842](https://github.com/WordPress/gutenberg/pull/82842)). +` ), + { filePath: 'packages/data/CHANGELOG.md' } + ); + expect( errors ).toEqual( [ + 'packages/data/CHANGELOG.md:5: unexpected heading inside Unreleased (## Enhancements). Subsections must be `###`.', + 'packages/data/CHANGELOG.md:9: unexpected heading inside Unreleased (#### Notes). Subsections must be `###`.', + ] ); + } ); + + test( 'rejects unknown section titles', () => { + const errors = validateChangelog( + changelog( `### Docs + +- Clarified README ([#82956](https://github.com/WordPress/gutenberg/pull/82956)). +` ), + { filePath: 'packages/dataviews/CHANGELOG.md' } + ); + expect( errors[ 0 ] ).toMatch( /unknown Unreleased section "Docs"/ ); + } ); + + test( 'rejects duplicate section titles', () => { + const errors = validateChangelog( + changelog( `### Bug Fixes + +- First ([#1](https://github.com/WordPress/gutenberg/pull/1)). + +### Bug Fixes + +- Second ([#2](https://github.com/WordPress/gutenberg/pull/2)). +` ), + { filePath: 'packages/ui/CHANGELOG.md' } + ); + expect( errors ).toEqual( [ + 'packages/ui/CHANGELOG.md:9: duplicate Unreleased section "Bug Fixes".', + ] ); + } ); + + test( 'rejects Unreleased sections that are out of order', () => { + const errors = validateChangelog( + changelog( `### Bug Fixes + +- Fixed something ([#1](https://github.com/WordPress/gutenberg/pull/1)). + +### Enhancements + +- Improved something ([#2](https://github.com/WordPress/gutenberg/pull/2)). +` ), + { filePath: 'packages/ui/CHANGELOG.md' } + ); + expect( errors ).toEqual( [ + 'packages/ui/CHANGELOG.md:9: Unreleased section "Enhancements" is out of order. Expected order: Stable Release, Breaking Changes, New Features, Enhancements, Deprecations, Bug Fixes, Internal, Documentation.', + ] ); + } ); + + test( 'rejects top-level entries without a Gutenberg pull request link', () => { + const errors = validateChangelog( + changelog( `### Bug Fixes + +- Fixed something without a link. +` ), + { filePath: 'packages/rich-text/CHANGELOG.md' } + ); + expect( errors ).toEqual( [ + 'packages/rich-text/CHANGELOG.md:7: Unreleased entry is missing a Gutenberg pull request link.', + ] ); + } ); + + test( 'rejects issue links in place of pull request links', () => { + const errors = validateChangelog( + changelog( `### Bug Fixes + +- Fixed something ([#12346](https://github.com/WordPress/gutenberg/issues/12346)). +` ), + { filePath: 'packages/sync/CHANGELOG.md' } + ); + expect( errors ).toEqual( [ + 'packages/sync/CHANGELOG.md:7: Unreleased entry is missing a Gutenberg pull request link.', + ] ); + } ); + + test( 'requirePr checks for this pull request link under Unreleased', () => { + const content = changelog( `### Internal + +- Touched the package ([#99](https://github.com/WordPress/gutenberg/pull/99)). +` ); + expect( + validateChangelog( content, { + filePath: 'packages/components/CHANGELOG.md', + requirePr: '100', + } ) + ).toEqual( [ + 'packages/components/CHANGELOG.md: Unreleased section must include a link to this PR: [#100](https://github.com/WordPress/gutenberg/pull/100)', + ] ); + expect( + validateChangelog( content, { + filePath: 'packages/components/CHANGELOG.md', + requirePr: '99', + } ) + ).toEqual( [] ); + } ); + + test( 'requirePr fails when Unreleased is missing', () => { + const errors = validateChangelog( '## 1.0.0 (2026-01-01)\n', { + filePath: 'packages/example/CHANGELOG.md', + requirePr: '42', + } ); + expect( errors[ 0 ] ).toMatch( /missing `## Unreleased`/ ); + } ); + + test( 'pr flags a link that sits under a published version', () => { + const content = `${ HEADER }## Unreleased + +## 1.0.0 (2026-01-01) + +### Bug Fixes + +- Fixed something ([#99](https://github.com/WordPress/gutenberg/pull/99)). +`; + expect( + validateChangelog( content, { + filePath: 'packages/data/CHANGELOG.md', + pr: '99', + } ) + ).toEqual( [ + 'packages/data/CHANGELOG.md: changelog entry for this PR must be under `## Unreleased`, not a published version: [#99](https://github.com/WordPress/gutenberg/pull/99)', + ] ); + expect( + validateChangelog( content, { + filePath: 'packages/data/CHANGELOG.md', + pr: '100', + } ) + ).toEqual( [] ); + } ); +} ); + +describe( 'resolveChangelogPath', () => { + test( 'treats inputs as relative to the invocation directory', () => { + expect( + resolveChangelogPath( 'packages/ui/CHANGELOG.md', '/repo' ) + ).toBe( resolve( '/repo', 'packages/ui/CHANGELOG.md' ) ); + expect( + resolveChangelogPath( 'packages/ui/CHANGELOG.md', REPO_ROOT ) + ).toBe( resolve( REPO_ROOT, 'packages/ui/CHANGELOG.md' ) ); + } ); +} ); + +describe( 'parseArgs', () => { + test( 'reads --pr, --require-pr, and paths', () => { + expect( + parseArgs( [ '--require-pr=82842', 'packages/data/CHANGELOG.md' ] ) + ).toEqual( { + pr: undefined, + requirePr: '82842', + paths: [ 'packages/data/CHANGELOG.md' ], + } ); + expect( + parseArgs( [ '--pr', '82842', 'packages/data/CHANGELOG.md' ] ) + ).toEqual( { + pr: '82842', + requirePr: undefined, + paths: [ 'packages/data/CHANGELOG.md' ], + } ); + } ); +} ); diff --git a/tools/validation/validate-changelogs.ts b/tools/validation/validate-changelogs.ts new file mode 100644 index 00000000000000..47a1e42e435960 --- /dev/null +++ b/tools/validation/validate-changelogs.ts @@ -0,0 +1,449 @@ +#!/usr/bin/env node +/** + * Validates package CHANGELOG.md Unreleased sections. + * + * Usage: + * node tools/validation/validate-changelogs.ts [path ...] + * node tools/validation/validate-changelogs.ts --pr=12345 packages/foo/CHANGELOG.md + * node tools/validation/validate-changelogs.ts --require-pr=12345 packages/foo/CHANGELOG.md + * + * With no paths, validates every package CHANGELOG under `packages/`. + * `--pr` flags a link to that pull request that sits outside Unreleased. + * `--require-pr` also requires Unreleased to cite that pull request. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs as parseNodeArgs } from 'node:util'; + +const __dirname = dirname( fileURLToPath( import.meta.url ) ); +const REPO_ROOT = resolve( __dirname, '../..' ); + +/** + * `npm run --workspace` sets cwd to this package. Prefer the directory + * where npm was invoked; otherwise treat paths as repo-root-relative. + * + * @param inputPath Changelog path from the CLI. + * @param fromDirectory Directory to resolve relative paths from. + */ +export function resolveChangelogPath( + inputPath: string, + fromDirectory: string = process.env.INIT_CWD || REPO_ROOT +): string { + return resolve( fromDirectory, inputPath ); +} + +/** + * Section titles allowed under ## Unreleased, in required order. + * Exact match is required by the validator. + */ +export const ALLOWED_SECTIONS = [ + 'Stable Release', + 'Breaking Changes', + 'New Features', + 'Enhancements', + 'Deprecations', + 'Bug Fixes', + 'Internal', + 'Documentation', +]; + +const GUTENBERG_PR_LINK = + /\[#\d+\]\(https:\/\/github\.com\/WordPress\/gutenberg\/pull\/\d+\)/; + +/** + * Pattern matching published release headings (e.g. `## 1.2.3 (2026-01-01)`). + */ +const VERSION_HEADING = /^## \d+\.\d+/; + +export type ValidateOptions = { + /** + * Path shown in error messages. + */ + filePath?: string; + + /** + * When set, a link to this pull request in the file must sit under Unreleased. + */ + pr?: string; + + /** + * When set, Unreleased must cite this pull request. + */ + requirePr?: string; +}; + +export type ParseArgsResult = { + pr: string | undefined; + requirePr: string | undefined; + paths: string[]; +}; + +type UnreleasedSection = { + /** + * Absolute start line of `## Unreleased` in the file (0-based). + */ + start: number; + + /** + * Lines from `## Unreleased` through the line before the next version heading. + */ + lines: string[]; +}; + +/** + * Locates the Unreleased block: from `## Unreleased` to the next published + * version heading (`## 1.2.3`), or end of file. Other `##` headings inside + * that range are left in place so later checks can reject them. + * + * @param lines Full changelog split into lines. + * @return The Unreleased slice, or `undefined` when the heading is missing. + */ +function findUnreleasedSection( + lines: string[] +): UnreleasedSection | undefined { + let start = -1; + for ( let i = 0; i < lines.length; i++ ) { + if ( /^## Unreleased\s*$/.test( lines[ i ] ) ) { + start = i; + break; + } + } + if ( start === -1 ) { + return undefined; + } + + let end = lines.length; + for ( let i = start + 1; i < lines.length; i++ ) { + if ( VERSION_HEADING.test( lines[ i ] ) ) { + end = i; + break; + } + } + + return { start, lines: lines.slice( start, end ) }; +} + +/** + * Requires Unreleased subsections to be `###` headings. `##` truncates + * release-tool parsing; `####` and other levels are not used. + * + * @param unreleased Unreleased section. + * @param filePath Path for error messages. + * @return Error messages. + */ +function validateSubsectionHeadings( + unreleased: UnreleasedSection, + filePath: string +): string[] { + const errors: string[] = []; + for ( let i = 1; i < unreleased.lines.length; i++ ) { + const line = unreleased.lines[ i ]; + if ( /^#{1,6} /.test( line ) && ! /^### /.test( line ) ) { + errors.push( + `${ filePath }:${ + unreleased.start + i + 1 + }: unexpected heading inside Unreleased (${ line.trim() }). Subsections must be \`###\`.` + ); + } + } + return errors; +} + +/** + * Requires Unreleased `###` titles to be from the known list, appear once, and + * follow `ALLOWED_SECTIONS` order. + * + * @param unreleased Unreleased section. + * @param filePath Path for error messages. + * @return Error messages. + */ +function validateSectionTitles( + unreleased: UnreleasedSection, + filePath: string +): string[] { + const errors: string[] = []; + const seenSections = new Set< string >(); + let previousIndex = -1; + + for ( let i = 1; i < unreleased.lines.length; i++ ) { + const heading = unreleased.lines[ i ].match( /^### (.+)$/ ); + if ( ! heading ) { + continue; + } + + const title = heading[ 1 ].trim(); + const lineNo = unreleased.start + i + 1; + const index = ALLOWED_SECTIONS.indexOf( title ); + + if ( index === -1 ) { + errors.push( + `${ filePath }:${ lineNo }: unknown Unreleased section "${ title }". Allowed: ${ ALLOWED_SECTIONS.join( + ', ' + ) }.` + ); + } + if ( seenSections.has( title ) ) { + errors.push( + `${ filePath }:${ lineNo }: duplicate Unreleased section "${ title }".` + ); + } + seenSections.add( title ); + + if ( index !== -1 ) { + if ( index < previousIndex ) { + errors.push( + `${ filePath }:${ lineNo }: Unreleased section "${ title }" is out of order. Expected order: ${ ALLOWED_SECTIONS.join( + ', ' + ) }.` + ); + } + previousIndex = index; + } + } + + return errors; +} + +/** + * Requires each top-level Unreleased list entry to include a Gutenberg pull + * request link. Nested bullets may rely on a link in the parent block. + * + * @param unreleased Unreleased section. + * @param filePath Path for error messages. + * @return Error messages. + */ +function validateEntryLinks( + unreleased: UnreleasedSection, + filePath: string +): string[] { + const errors: string[] = []; + + for ( let i = 1; i < unreleased.lines.length; ) { + const line = unreleased.lines[ i ]; + // Top-level list items are "- " (not "--" prose leftovers). + if ( ! /^-\s+/.test( line ) ) { + i++; + continue; + } + + const entryStart = unreleased.start + i + 1; + const blockLines = [ line ]; + i++; + while ( i < unreleased.lines.length ) { + const next = unreleased.lines[ i ]; + if ( /^#{1,6} /.test( next ) || /^-\s+/.test( next ) ) { + break; + } + // Blank lines may sit between a parent bullet and nested children; + // skip them without ending the entry. + if ( next === '' ) { + i++; + continue; + } + if ( /^\s/.test( next ) ) { + blockLines.push( next ); + i++; + continue; + } + break; + } + + if ( ! GUTENBERG_PR_LINK.test( blockLines.join( '\n' ) ) ) { + errors.push( + `${ filePath }:${ entryStart }: Unreleased entry is missing a Gutenberg pull request link.` + ); + } + } + + return errors; +} + +/** + * Markdown link for a Gutenberg pull request. + * + * @param pr Pull request number. + */ +function pullRequestLink( pr: string ): string { + return `[#${ pr }](https://github.com/WordPress/gutenberg/pull/${ pr })`; +} + +/** + * If this PR's pull link appears in the file, it must be under Unreleased. + * Catches notes that landed in a published version after a package release. + * + * @param content Full changelog markdown. + * @param unreleased Unreleased section, if present. + * @param filePath Path for error messages. + * @param pr Pull request number. + * @return Error messages. + */ +function validatePrLinkPlacement( + content: string, + unreleased: UnreleasedSection | undefined, + filePath: string, + pr: string +): string[] { + const requiredLink = pullRequestLink( pr ); + if ( ! content.includes( requiredLink ) ) { + return []; + } + if ( unreleased?.lines.join( '\n' ).includes( requiredLink ) ) { + return []; + } + return [ + `${ filePath }: changelog entry for this PR must be under \`## Unreleased\`, not a published version: ${ requiredLink }`, + ]; +} + +/** + * When `--require-pr` is set, requires Unreleased to cite that pull request. + * + * @param unreleased Unreleased section. + * @param filePath Path for error messages. + * @param requirePr Pull request number that must appear. + * @return Error messages. + */ +function validateRequiredPrLink( + unreleased: UnreleasedSection, + filePath: string, + requirePr: string +): string[] { + const requiredLink = pullRequestLink( requirePr ); + if ( unreleased.lines.join( '\n' ).includes( requiredLink ) ) { + return []; + } + return [ + `${ filePath }: Unreleased section must include a link to this PR: ${ requiredLink }`, + ]; +} + +/** + * Validates a package CHANGELOG's Unreleased section. + * + * @param content Changelog markdown. + * @param options Validation options. + * @return Error messages (empty when valid). + */ +export function validateChangelog( + content: string, + options: ValidateOptions = {} +): string[] { + const filePath = options.filePath ?? 'CHANGELOG.md'; + const unreleased = findUnreleasedSection( content.split( /\r?\n/ ) ); + const pr = options.pr ?? options.requirePr; + const placementErrors = pr + ? validatePrLinkPlacement( content, unreleased, filePath, pr ) + : []; + + if ( ! unreleased ) { + if ( options.requirePr ) { + return [ + `${ filePath }: missing \`## Unreleased\` section (required when checking for PR #${ options.requirePr }).`, + ...placementErrors, + ]; + } + return placementErrors; + } + + return [ + ...validateSubsectionHeadings( unreleased, filePath ), + ...validateSectionTitles( unreleased, filePath ), + ...validateEntryLinks( unreleased, filePath ), + ...placementErrors, + ...( options.requirePr + ? validateRequiredPrLink( unreleased, filePath, options.requirePr ) + : [] ), + ]; +} + +/** + * @param argv CLI arguments (without node / script path). + */ +export function parseArgs( argv: string[] ): ParseArgsResult { + const { values, positionals } = parseNodeArgs( { + args: argv, + allowPositionals: true, + strict: true, + options: { + pr: { type: 'string' }, + 'require-pr': { type: 'string' }, + }, + } ); + + return { + pr: values.pr, + requirePr: values[ 'require-pr' ], + paths: positionals, + }; +} + +/** + * @param argv CLI arguments (without node / script path). + * @return Exit code. + */ +export function run( argv: string[] ): number { + let pr: string | undefined; + let requirePr: string | undefined; + let paths: string[]; + try { + ( { pr, requirePr, paths } = parseArgs( argv ) ); + } catch ( error ) { + const message = + error instanceof Error ? error.message : String( error ); + console.error( message ); + return 1; + } + + const files = + paths.length > 0 + ? paths.map( ( filePath ) => resolveChangelogPath( filePath ) ) + : readdirSync( join( REPO_ROOT, 'packages' ), { + withFileTypes: true, + } ) + .filter( ( entry ) => entry.isDirectory() ) + .map( ( entry ) => + join( + REPO_ROOT, + 'packages', + entry.name, + 'CHANGELOG.md' + ) + ) + .filter( ( filePath ) => existsSync( filePath ) ) + .sort(); + + if ( files.length === 0 ) { + console.error( 'No CHANGELOG.md files to validate.' ); + return 1; + } + + let hasErrors = false; + for ( const filePath of files ) { + if ( ! existsSync( filePath ) ) { + console.error( `${ filePath }: file not found.` ); + hasErrors = true; + continue; + } + const content = readFileSync( filePath, 'utf8' ); + const relativePath = relative( REPO_ROOT, filePath ); + const errors = validateChangelog( content, { + filePath: relativePath, + pr, + requirePr, + } ); + for ( const error of errors ) { + console.error( error ); + hasErrors = true; + } + } + + return hasErrors ? 1 : 0; +} + +if ( + process.argv[ 1 ] && + import.meta.url === pathToFileURL( resolve( process.argv[ 1 ] ) ).href +) { + process.exitCode = run( process.argv.slice( 2 ) ); +} diff --git a/tsconfig.json b/tsconfig.json index 87e746dc14d968..c69d657a50ed6c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -93,6 +93,7 @@ { "path": "storybook" }, { "path": "tools/monorepo" }, { "path": "tools/pr-meta" }, + { "path": "tools/validation" }, { "path": "test/e2e" }, { "path": "test/performance" }, { "path": "test/storybook-playwright" },