From 881fdc40bbbb87532afb139e41b2b4e53bbe8df8 Mon Sep 17 00:00:00 2001 From: Nilambar Sharma Date: Tue, 19 Mar 2024 15:27:51 +0545 Subject: [PATCH 01/72] Fix typos and update docs --- .readme-partials/INSTALLATION.md | 2 +- README.md | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.readme-partials/INSTALLATION.md b/.readme-partials/INSTALLATION.md index d4d062e..b36d31c 100644 --- a/.readme-partials/INSTALLATION.md +++ b/.readme-partials/INSTALLATION.md @@ -8,7 +8,7 @@ In order to make code changes to WP-CLI, you'll need to set up this `wp-cli-dev` Before you can proceed further, you'll need to make sure you have [Composer](https://getcomposer.org/), PHP, and a functioning MySQL or MariaDB server on your local machine. -Once the prequisites are met, clone the GitHub repository and run the installation process: +Once the prerequisites are met, clone the GitHub repository and run the installation process: ```bash git clone https://github.com/wp-cli/wp-cli-dev wp-cli-dev diff --git a/README.md b/README.md index 5e9df3f..eb7549f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ In order to make code changes to WP-CLI, you'll need to set up this `wp-cli-dev` Before you can proceed further, you'll need to make sure you have [Composer](https://getcomposer.org/), PHP, and a functioning MySQL or MariaDB server on your local machine. -Once the prequisites are met, clone the GitHub repository and run the installation process: +Once the prerequisites are met, clone the GitHub repository and run the installation process: ```bash git clone https://github.com/wp-cli/wp-cli-dev wp-cli-dev @@ -64,13 +64,22 @@ wp maintenance Lists all contributors to this release. ~~~ -wp maintenance contrib-list [--format=] +wp maintenance contrib-list [] [...] [--format=] ~~~ Run within the main WP-CLI project repository. **OPTIONS** + [] + Name of the repository to fetch the release notes for. If no user/org + was provided, 'wp-cli' org is assumed. If no repo is passed, release + notes for the entire org state since the last bundle release are fetched. + + [...] + Name of one or more milestones to fetch the release notes for. If none + are passed, the current open one is assumed. + [--format=] Render output in a specific format. --- @@ -156,7 +165,7 @@ wp maintenance release-notes [] [...] [--source=] [--fo [...] Name of one or more milestones to fetch the release notes for. If none - are passed, the currently open one is assumed. + are passed, the current open one is assumed. [--source=] Choose source from where to copy content. @@ -207,7 +216,7 @@ We appreciate you taking the initiative to contribute to this project. Contributing isn’t limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation. -For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policies and guidelines. +For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines. ### Reporting a bug @@ -225,6 +234,6 @@ Once you've decided to commit the time to seeing your pull request through, [ple ## Support -Github issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support +GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support From b81b3a13881afffb8d4dfb9ad13fdee57da803ac Mon Sep 17 00:00:00 2001 From: Mike Straw Date: Tue, 17 Sep 2024 10:38:29 -0700 Subject: [PATCH 02/72] Add link to database information --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eb7549f..ea9be71 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ In order to make code changes to WP-CLI, you'll need to set up this `wp-cli-dev` 2. Install all Composer dependencies for a complete `wp-cli-bundle` setup, while symlinking all of the previously cloned packages into the Composer `vendor` folder. 3. Symlink all folder in `vendor` into corresponding `vendor` folders in each repository, thus making the centralized functionality based on Composer available in each repository subfolder. -Before you can proceed further, you'll need to make sure you have [Composer](https://getcomposer.org/), PHP, and a functioning MySQL or MariaDB server on your local machine. +Before you can proceed further, you'll need to make sure you have [Composer](https://getcomposer.org/), PHP, and a [functioning MySQL or MariaDB server on your local machine](https://github.com/wp-cli/wp-cli-tests?tab=readme-ov-file#the-database-credentials). Once the prerequisites are met, clone the GitHub repository and run the installation process: From 9a06612c00f7ee664c836c73b3d1b69599c485e2 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 6 Dec 2024 22:35:35 +0100 Subject: [PATCH 03/72] Update `wp-cli/php-cli-tools` dependency --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5e6784c..2023a95 100644 --- a/composer.json +++ b/composer.json @@ -221,7 +221,7 @@ "wp-cli/media-command": "dev-main", "wp-cli/mustangostang-spyc": "dev-master as 0.6.x-dev", "wp-cli/package-command": "dev-main", - "wp-cli/php-cli-tools": "dev-master as 0.11.x-dev", + "wp-cli/php-cli-tools": "dev-master as 0.12.x-dev", "wp-cli/profile-command": "dev-main", "wp-cli/restful": "dev-main", "wp-cli/rewrite-command": "dev-main", From c9d048b02cb0d2cf82f082a1eed4dbe6133dbc2f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Sat, 1 Feb 2025 12:07:39 +0100 Subject: [PATCH 04/72] Fix wp-cli-bundle branch name --- foreach-bundle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreach-bundle b/foreach-bundle index 4c6d86a..8396bf4 100755 --- a/foreach-bundle +++ b/foreach-bundle @@ -6,7 +6,7 @@ get_bundle_dependencies() { echo "wp-cli/wp-cli-bundle" - curl --silent "https://raw.githubusercontent.com/wp-cli/wp-cli-bundle/master/composer.json" \ + curl --silent "https://raw.githubusercontent.com/wp-cli/wp-cli-bundle/main/composer.json" \ | jq --raw-output '."require" | keys[] | select(startswith("wp-cli/"))' } From 18742942da598bdaa7ad1370c4649e5bbfebe9db Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 7 May 2025 14:05:43 +0200 Subject: [PATCH 05/72] Require PHP 7.2.24+ --- composer.json | 2 +- phpcs.xml.dist | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 2023a95..5692d8f 100644 --- a/composer.json +++ b/composer.json @@ -194,7 +194,7 @@ } ], "require": { - "php": ">=5.6.20", + "php": ">=7.2.24", "ext-json": "*", "ext-dom": "*", "wp-cli/admin-command": "dev-main", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 142a306..9edfc31 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -38,7 +38,7 @@ - + From 9039c984f5173545fffee5746a57ea942c50481f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 14 Jul 2026 16:23:38 +0200 Subject: [PATCH 56/72] Bump `wp-cli/php-cli-tools` requirement --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7ecf94e..ebdd611 100644 --- a/composer.json +++ b/composer.json @@ -238,7 +238,7 @@ "wp-cli/media-command": "dev-main", "wp-cli/mustangostang-spyc": "dev-master as 0.6.x-dev", "wp-cli/package-command": "dev-main", - "wp-cli/php-cli-tools": "dev-main as 0.12.x-dev", + "wp-cli/php-cli-tools": "dev-main as 0.13.x-dev", "wp-cli/profile-command": "dev-main", "wp-cli/restful": "dev-main", "wp-cli/rewrite-command": "dev-main", From 9de70b1e4b1e345a19f955f3ddb9a69bd5f27365 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Mon, 20 Jul 2026 16:14:07 +0000 Subject: [PATCH 57/72] Update file(s) from wp-cli/.github --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 844ffe2..c703112 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false From 828bcea7432fd56a3b0ff20f0ae025f436d85f5c Mon Sep 17 00:00:00 2001 From: swissspidy Date: Wed, 29 Jul 2026 14:30:06 +0000 Subject: [PATCH 58/72] Update file(s) from wp-cli/.github --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d6c7b8b..bf50ffc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,8 @@ updates: open-pull-requests-limit: 10 labels: - scope:distribution + cooldown: + default-days: 7 - package-ecosystem: github-actions directory: "/" schedule: @@ -14,4 +16,6 @@ updates: open-pull-requests-limit: 10 labels: - scope:distribution + cooldown: + default-days: 7 From 2951589b78f9c006deeb83b0089b0c50d7548c44 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Fri, 7 Aug 2026 09:19:37 +0000 Subject: [PATCH 59/72] Update file(s) from wp-cli/.github --- .github/workflows/issue-triage.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 6833470..bf816ae 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -16,9 +16,14 @@ name: Issue and PR Triage permissions: issues: write pull-requests: write - actions: write contents: read models: read + # A caller can only cap a reusable workflow's permissions, never raise them, + # so `actions: write` has to be granted here for the dispatch job downstream + # to work at all. The reusable workflow narrows it to that single job, so the + # `pull_request_target` job does not receive it. Removing it here breaks + # dispatching (see #271/#272); narrow it there instead. + actions: write jobs: issue-triage: From 86409c335775a0ad45b93049056d1223435cc1fe Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 7 Aug 2026 20:25:09 +0000 Subject: [PATCH 60/72] Stop scanning wp-cli/wp-cli.github.com for contributors and release notes The website repository is no longer maintained, so it never has an open milestone to read from. Both bundle-wide code paths skipped it harmlessly at runtime, but it still cost an API call per invocation and implied the repo was part of a release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xw2XwqE3C4cGGZ6EbS8p3V --- .maintenance/src/Contrib_List_Command.php | 1 - .maintenance/src/Release_Notes_Command.php | 1 - 2 files changed, 2 deletions(-) diff --git a/.maintenance/src/Contrib_List_Command.php b/.maintenance/src/Contrib_List_Command.php index d62fb28..5fd8147 100644 --- a/.maintenance/src/Contrib_List_Command.php +++ b/.maintenance/src/Contrib_List_Command.php @@ -52,7 +52,6 @@ public function __invoke( $args, $assoc_args ) { 'wp-cli/wp-cli-bundle', 'wp-cli/wp-cli', 'wp-cli/handbook', - 'wp-cli/wp-cli.github.com', ]; } diff --git a/.maintenance/src/Release_Notes_Command.php b/.maintenance/src/Release_Notes_Command.php index 8565659..1b92f6e 100644 --- a/.maintenance/src/Release_Notes_Command.php +++ b/.maintenance/src/Release_Notes_Command.php @@ -72,7 +72,6 @@ private function get_bundle_release_notes( $source, $format ) { 'wp-cli/wp-cli-bundle', 'wp-cli/wp-cli', 'wp-cli/handbook', - 'wp-cli/wp-cli.github.com', ) as $repo ) { $milestones = GitHub::get_project_milestones( $repo ); From a2e0c3c1f3ff9db3158624065f55f3abe4752dd1 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Thu, 13 Aug 2026 14:06:25 +0000 Subject: [PATCH 61/72] Update file(s) from wp-cli/.github --- .github/actionlint.yml | 13 +++++++++++++ .github/workflows/issue-triage.yml | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .github/actionlint.yml diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 0000000..813317a --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,13 @@ +# Configuration for actionlint, run by the `actionlint` job in +# `.github/workflows/reusable-code-quality.yml`. +paths: + .github/workflows/**/*.{yml,yaml}: + ignore: + # `copilot-requests` is a real permission scope - it is what lets a + # workflow authenticate the Copilot CLI with the built-in `GITHUB_TOKEN` + # instead of a personal access token - but actionlint's hard-coded scope + # list has not caught up with it yet, so it reports every use as unknown. + # Drop this entry once actionlint ships the scope, and it will go back to + # catching genuine typos in permission names. + # See https://docs.github.com/en/copilot/how-tos/copilot-cli/use-copilot-cli-in-actions + - 'unknown permission scope "copilot-requests"' diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index bf816ae..0795944 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -17,7 +17,10 @@ permissions: issues: write pull-requests: write contents: read - models: read + # A caller can only cap a reusable workflow's permissions, so the scope the + # Copilot CLI needs has to be granted here too. `models: read` was for the + # GitHub Models provider, which `actions/ai-inference` v3 removed. + copilot-requests: write # A caller can only cap a reusable workflow's permissions, never raise them, # so `actions: write` has to be granted here for the dispatch job downstream # to work at all. The reusable workflow narrows it to that single job, so the From c5b70d3e01a62a09b2173c9487cfa19640a0da9a Mon Sep 17 00:00:00 2001 From: schlessera Date: Wed, 26 Aug 2026 13:50:49 +0000 Subject: [PATCH 62/72] Update file(s) from wp-cli/.github --- .github/workflows/copilot-setup-steps.yml | 35 +++++++++++++++++++++++ AGENTS.md | 23 +++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index c703112..f2541f7 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -19,6 +19,16 @@ jobs: permissions: contents: read + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_TCP_PORT: 3306 + WP_CLI_TEST_DBROOTUSER: root + WP_CLI_TEST_DBROOTPASS: root + WP_CLI_TEST_DBNAME: wp_cli_test + WP_CLI_TEST_DBUSER: wp_cli_test + WP_CLI_TEST_DBPASS: password1 + WP_CLI_TEST_DBHOST: 127.0.0.1:3306 + steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,3 +55,28 @@ jobs: uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} + + # Without a database the Behat runner silently falls back to SQLite, so the + # agent's test runs are not the ones CI will do. Mirrors the setup in + # reusable-functional.yml. + - name: Check existence of behat.yml file + id: check_behat_file + run: echo "files_exists=$(test -f behat.yml && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Setup MySQL Server + if: steps.check_behat_file.outputs.files_exists == 'true' + uses: shogo82148/actions-setup-mysql@62da9377d83991fce27b6ed2a397d3306121e41d # v1 + with: + mysql-version: '8.0' # Standard MySQL version for these tests + auto-start: true + root-password: ${{ env.WP_CLI_TEST_DBROOTPASS }} + user: ${{ env.WP_CLI_TEST_DBUSER }} + password: ${{ env.WP_CLI_TEST_DBPASS }} + + - name: Remove system MySQL binary + if: steps.check_behat_file.outputs.files_exists == 'true' + run: sudo rm -f /usr/bin/mysql /usr/bin/mysqldump + + - name: Prepare test database + if: steps.check_behat_file.outputs.files_exists == 'true' + run: composer prepare-tests diff --git a/AGENTS.md b/AGENTS.md index 1ff84f6..908c63d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,27 @@ When contributing to this package, please adhere to the following guidelines: ### Building and running -Before submitting any changes, it is crucial to validate them by running the full suite of static code analysis and tests. To run the full suite of checks, execute the following command: `composer test`. +Run the narrowest check that covers your change: -This single command ensures that your changes meet all the quality gates of the project. While you can run the individual steps separately, it is highly recommended to use this single command to ensure a comprehensive validation. +| You changed | Run | +| --- | --- | +| Any PHP file | `composer phpcs -- ` and `composer phpstan` | +| Logic with unit test coverage | `composer phpunit -- --filter ` | +| One feature file | `composer behat -- features/.feature` | +| One scenario | `composer behat -- features/.feature:` | + +After a failure, `composer behat-rerun` re-runs only the scenarios that failed, and `composer behat -- --stop-on-failure` bails out at the first one instead of working through the rest. + +`composer test` runs every suite, the whole Behat suite included. In most packages that means installing WordPress from scratch once per scenario, which takes tens of minutes and needs `jq`, a MySQL or MariaDB client with a prepared test database, and network access to WordPress.org. Run it before opening a pull request when you have touched something cross-cutting. Do not run it to check a two-line change. + +Set these first, so the tools report problems compactly instead of drawing progress bars and ANSI color into output you are going to read back: + +```bash +export NO_COLOR=1 +export WP_CLI_TEST_QUIET=1 +``` + +Note that a green `composer test` is not the same as a green CI. `actionlint` and `typos` also run on every pull request and are not part of it. ### Useful Composer Commands @@ -24,6 +42,7 @@ The project uses Composer to manage dependencies and run scripts. The following * `composer install`: Install dependencies. * `composer test`: Run the full test suite, including linting, code style checks, static analysis, and unit/behavior tests. * `composer lint`: Check for syntax errors. +* `composer lint-gherkin`: Check the Behat feature files for style violations. * `composer phpcs`: Check for code style violations. * `composer phpcbf`: Automatically fix code style violations. * `composer phpstan`: Run static analysis. From 96713a0c2bc8b50c85111c6bc641a5dc86a31527 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Thu, 27 Aug 2026 07:07:15 +0000 Subject: [PATCH 63/72] Update file(s) from wp-cli/.github --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bf50ffc..991c6c3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,17 @@ updates: - scope:distribution cooldown: default-days: 7 + # Only wp-cli-tests carries a package.json, holding the pinned version of the + # Gherkin linter. This is a no-op in every other repository. + - package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + labels: + - scope:distribution + cooldown: + default-days: 7 - package-ecosystem: github-actions directory: "/" schedule: From 9dd2f6fcd4829f4314dd3f65613c3508c0c41ecb Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 15 Sep 2026 09:19:11 +0000 Subject: [PATCH 64/72] Let contrib-list and release-notes target a specific bundle release Both commands guessed what a release contains: the lowest open milestone of the release repositories, and the packages listed in the lock of the *previous* bundle release. That misses every package bundled since (for 3.0.0: ability, ai, block and site-health), and contrib-list additionally dropped the first open milestone before iterating, so with exactly one open milestone it listed nobody from wp-cli-bundle, wp-cli or handbook. Adds `--release=` and `--bundle-ref=` to both commands: - The release milestone is looked up by title in wp-cli/wp-cli-bundle, wp-cli/wp-cli and wp-cli/handbook (any state, so reruns after the milestones were closed still work). - The bundled packages come from the composer.lock at the release tag (or the given ref, or the default branch before the tag exists), and the previous release's lock only serves as the baseline. A package that is not in the previous lock is newly bundled and contributes all of its releases. Package milestones newer than the version the release locks are excluded. - `packages-dev` is considered too, which is where package-command and wp-cli-tests live. The shared lookups live in a new Bundle helper. Progress messages now go through WP_CLI::debug so the generated markdown can be redirected to a file as-is; the totals stay in the output. The obsolete i18n-command v2 shim, which double-counted that package's pull requests, is gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016VDHkafhoHtgAPuYkvgvqh --- .maintenance/src/Bundle.php | 256 +++++++++++++++++++++ .maintenance/src/Contrib_List_Command.php | 128 ++++------- .maintenance/src/Release_Notes_Command.php | 121 +++------- README.md | 30 ++- 4 files changed, 361 insertions(+), 174 deletions(-) create mode 100644 .maintenance/src/Bundle.php diff --git a/.maintenance/src/Bundle.php b/.maintenance/src/Bundle.php new file mode 100644 index 0000000..109fe2d --- /dev/null +++ b/.maintenance/src/Bundle.php @@ -0,0 +1,256 @@ + 'all' ] ) as $milestone ) { + if ( ltrim( $milestone->title, 'v' ) === $release ) { + return $milestone; + } + } + + WP_CLI::warning( "Couldn't find milestone '{$release}' in repository '{$repo}'." ); + + return null; + } + + return array_reduce( + GitHub::get_project_milestones( $repo ), + static function ( $lowest, $milestone ) { + if ( null === $lowest ) { + return $milestone; + } + + return version_compare( $milestone->title, $lowest->title, '<' ) ? $milestone : $lowest; + } + ); + } + + /** + * Gets the ref and composer.lock that describe the packages of a bundle + * release. + * + * @param string|null $release Bundle release version, e.g. '3.0.0'. Its + * tag is used when it exists already. + * @param string|null $bundle_ref Explicit ref; wins over the release tag. + * + * @return array{0: string, 1: array} Ref and decoded lockfile. + */ + public static function get_release_lock( $release = null, $bundle_ref = null ) { + if ( $bundle_ref ) { + return [ $bundle_ref, self::get_lock( $bundle_ref ) ]; + } + + if ( $release ) { + $tag = 'v' . ltrim( $release, 'v' ); + $lock = self::get_lock( $tag, false ); + + if ( $lock ) { + return [ $tag, $lock ]; + } + + WP_CLI::debug( "Tag {$tag} does not exist in " . self::REPO . ' yet, using the default branch.', 'bundle' ); + } + + $branch = GitHub::get_default_branch( self::REPO ); + + return [ $branch, self::get_lock( $branch ) ]; + } + + /** + * Gets the ref and composer.lock of the bundle release before the given + * one, i.e. the highest closed milestone below it. + * + * @param string|null $release Bundle release version, e.g. '3.0.0'. Without + * it, the highest closed milestone is used. + * + * @return array{0: string|null, 1: array|null} Ref and decoded lockfile, + * both null without a + * previous release. + */ + public static function get_previous_release_lock( $release = null ) { + $release = $release ? ltrim( $release, 'v' ) : null; + $previous = null; + + foreach ( GitHub::get_project_milestones( self::REPO, [ 'state' => 'closed' ] ) as $milestone ) { + $title = ltrim( $milestone->title, 'v' ); + + if ( $release && ! version_compare( $title, $release, '<' ) ) { + continue; + } + + if ( null === $previous || version_compare( $title, $previous, '>' ) ) { + $previous = $title; + } + } + + if ( null === $previous ) { + return [ null, null ]; + } + + $tag = "v{$previous}"; + + return [ $tag, self::get_lock( $tag ) ]; + } + + /** + * Fetches and decodes the composer.lock of wp-cli/wp-cli-bundle at a ref. + * + * @param string $ref Branch, tag or commit. + * @param bool $throw_errors Whether a missing lockfile is fatal. + * + * @return array|false + */ + public static function get_lock( $ref, $throw_errors = true ) { + $url = sprintf( 'https://raw.githubusercontent.com/%s/%s/composer.lock', self::REPO, $ref ); + $response = Utils\http_request( 'GET', $url ); + + if ( 200 !== $response->status_code ) { + if ( ! $throw_errors ) { + return false; + } + + WP_CLI::error( sprintf( 'Could not fetch %s (HTTP code %d)', $url, $response->status_code ) ); + } + + return json_decode( $response->body, true ); + } + + /** + * Lists the bundled packages of a lockfile with their versions. + * + * @param array $lock Decoded composer.lock. + * + * @return array Package name => version without leading 'v', + * sorted by name. + */ + public static function get_packages( array $lock ) { + $packages = []; + + foreach ( [ 'packages', 'packages-dev' ] as $section ) { + if ( empty( $lock[ $section ] ) ) { + continue; + } + + foreach ( $lock[ $section ] as $package ) { + if ( ! self::is_bundled_package( $package['name'] ) ) { + continue; + } + + $packages[ $package['name'] ] = ltrim( $package['version'], 'v' ); + } + } + + ksort( $packages ); + + return $packages; + } + + /** + * Gets the closed milestones of a package that shipped in a bundle + * release: newer than the version the previous release locked, and not + * newer than the version this release locks. A package without a + * previous version is newly bundled, so all of its releases count. + * + * @param string $package Package name. + * @param string|null $previous_version Version locked by the previous release. + * @param string $current_version Version locked by this release. + * + * @return array + */ + public static function get_shipped_milestones( $package, $previous_version, $current_version ) { + $milestones = GitHub::get_project_milestones( $package, [ 'state' => 'closed' ] ); + + return array_values( + array_filter( + $milestones, + static function ( $milestone ) use ( $previous_version, $current_version ) { + $title = ltrim( $milestone->title, 'v' ); + + if ( ! self::is_numeric_version( $title ) ) { + return false; + } + + if ( self::is_numeric_version( $previous_version ) + && ! version_compare( $title, $previous_version, '>' ) ) { + return false; + } + + if ( self::is_numeric_version( $current_version ) + && version_compare( $title, $current_version, '>' ) ) { + return false; + } + + return true; + } + ) + ); + } + + /** + * Checks whether a package's contributors and release notes belong to a + * bundle release. + * + * @param string $name Package name. + * + * @return bool + */ + public static function is_bundled_package( $name ) { + return (bool) preg_match( '#^wp-cli/.+-command$#', $name ) + || in_array( $name, self::OTHER_PACKAGES, true ); + } + + /** + * Checks whether a version can be compared, as opposed to `dev-main`. + * + * @param string|null $version Version string, or null when unknown. + * + * @return bool + */ + private static function is_numeric_version( $version ) { + return null !== $version && (bool) preg_match( '/^\d+(\.\d+)*/', $version ); + } +} diff --git a/.maintenance/src/Contrib_List_Command.php b/.maintenance/src/Contrib_List_Command.php index 5fd8147..bf69cec 100644 --- a/.maintenance/src/Contrib_List_Command.php +++ b/.maintenance/src/Contrib_List_Command.php @@ -21,6 +21,19 @@ final class Contrib_List_Command { * : Name of one or more milestones to fetch the release notes for. If none * are passed, the current open one is assumed. * + * [--release=] + * : Version of the bundle release, e.g. 3.0.0. Uses the milestone with + * that title in wp-cli/wp-cli-bundle, wp-cli/wp-cli and wp-cli/handbook + * instead of the currently open one, and reads the bundled packages from + * the composer.lock at the release tag of wp-cli/wp-cli-bundle. Only + * applies when no repo is passed. + * + * [--bundle-ref=] + * : Branch, tag or commit of wp-cli/wp-cli-bundle whose composer.lock lists + * the packages and versions shipped in this release. Defaults to the + * release tag when --release is passed and the tag exists, and to the + * default branch otherwise. Only applies when no repo is passed. + * * [--format=] * : Render output in a specific format. * --- @@ -46,13 +59,12 @@ public function __invoke( $args, $assoc_args ) { $milestone_names = $args; + $release = Utils\get_flag_value( $assoc_args, 'release' ); + $bundle_ref = Utils\get_flag_value( $assoc_args, 'bundle-ref' ); + if ( empty( $repos ) ) { $use_bundle = true; - $repos = [ - 'wp-cli/wp-cli-bundle', - 'wp-cli/wp-cli', - 'wp-cli/handbook', - ]; + $repos = Bundle::RELEASE_REPOS; } $contributors = array(); @@ -95,21 +107,15 @@ public function __invoke( $args, $assoc_args ) { ); } } else { - $milestones = GitHub::get_project_milestones( $repo ); - // Cheap way to get the latest milestone - $milestone = array_shift( $milestones ); - if ( ! $milestone ) { - continue; - } + $milestone = Bundle::get_release_milestone( $repo, $use_bundle ? $release : null ); + $milestones = $milestone ? [ $milestone ] : []; } - $entries = array(); + foreach ( $milestones as $milestone ) { - WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'release-notes' ); - WP_CLI::log( 'Current open ' . $repo . ' milestone: ' . $milestone->title ); + WP_CLI::debug( "Using milestone '{$milestone->title}' for repo '{$repo}'", 'contrib-list' ); $pull_requests = GitHub::get_project_milestone_pull_requests( $repo, $milestone->number ); $repo_contributors = GitHub::parse_contributors_from_pull_requests( $pull_requests ); - WP_CLI::log( ' - Contributors: ' . count( $repo_contributors ) ); - WP_CLI::log( ' - Pull requests: ' . count( $pull_requests ) ); + WP_CLI::debug( count( $repo_contributors ) . ' contributors, ' . count( $pull_requests ) . " pull requests in '{$repo}' milestone '{$milestone->title}'", 'contrib-list' ); $pull_request_count += count( $pull_requests ); $contributors = array_merge( $contributors, $repo_contributors ); } @@ -117,80 +123,36 @@ public function __invoke( $args, $assoc_args ) { if ( $use_bundle ) { // Identify all command dependencies and their contributors + list( $ref, $lock ) = Bundle::get_release_lock( $release, $bundle_ref ); + list( $previous_ref, $previous_lock ) = Bundle::get_previous_release_lock( $release ); - $bundle = 'wp-cli/wp-cli-bundle'; + WP_CLI::debug( "Bundled packages read from wp-cli/wp-cli-bundle@{$ref}, previous release: " . ( $previous_ref ?: 'none' ), 'contrib-list' ); + + $previous_versions = $previous_lock ? Bundle::get_packages( $previous_lock ) : []; + + foreach ( Bundle::get_packages( $lock ) as $package_name => $version ) { + $previous_version = isset( $previous_versions[ $package_name ] ) ? $previous_versions[ $package_name ] : null; - $milestones = GitHub::get_project_milestones( $bundle, array( 'state' => 'closed' ) ); - $milestone = array_reduce( - $milestones, - function ( $tag, $milestone ) { - if ( ! $tag ) { - return $milestone->title; - } - return version_compare( $milestone->title, $tag, '>' ) ? $milestone->title : $tag; - } - ); - $tag = ! empty( $milestone ) ? "v{$milestone}" : GitHub::get_default_branch( $bundle ); - - $composer_lock_url = sprintf( 'https://raw.githubusercontent.com/%s/%s/composer.lock', $bundle, $tag ); - WP_CLI::log( 'Fetching ' . $composer_lock_url ); - $response = Utils\http_request( 'GET', $composer_lock_url ); - if ( 200 !== $response->status_code ) { - WP_CLI::error( sprintf( 'Could not fetch composer.json (HTTP code %d)', $response->status_code ) ); - } - $composer_json = json_decode( $response->body, true ); - - // TODO: Only need for initial v2. - $composer_json['packages'][] = array( - 'name' => 'wp-cli/i18n-command', - 'version' => 'v2', - ); - usort( - $composer_json['packages'], - function ( $a, $b ) { - return $a['name'] < $b['name'] ? -1 : 1; - } - ); - - foreach ( $composer_json['packages'] as $package ) { - $package_name = $package['name']; - $version_constraint = str_replace( 'v', '', $package['version'] ); - if ( ! preg_match( '#^wp-cli/.+-command$#', $package_name ) - && ! in_array( - $package_name, - array( - 'wp-cli/wp-cli-tests', - 'wp-cli/regenerate-readme', - 'wp-cli/autoload-splitter', - 'wp-cli/wp-config-transformer', - 'wp-cli/php-cli-tools', - 'wp-cli/spyc', - ), - true - ) ) { - continue; - } // Closed milestones denote a tagged release - $milestones = GitHub::get_project_milestones( $package_name, array( 'state' => 'closed' ) ); - $milestone_ids = array(); - $milestone_titles = array(); - foreach ( $milestones as $milestone ) { - if ( ! version_compare( $milestone->title, $version_constraint, '>' ) ) { - continue; - } - $milestone_ids[] = $milestone->number; - $milestone_titles[] = $milestone->title; - } + $milestones = Bundle::get_shipped_milestones( $package_name, $previous_version, $version ); + // No shipped releases for this milestone. - if ( empty( $milestone_ids ) ) { + if ( empty( $milestones ) ) { continue; } - WP_CLI::log( 'Closed ' . $package_name . ' milestone(s): ' . implode( ', ', $milestone_titles ) ); - foreach ( $milestone_ids as $milestone_id ) { - $pull_requests = GitHub::get_project_milestone_pull_requests( $package_name, $milestone_id ); + + $milestone_titles = array_map( + static function ( $milestone ) { + return $milestone->title; + }, + $milestones + ); + WP_CLI::debug( "Closed {$package_name} milestone(s): " . implode( ', ', $milestone_titles ), 'contrib-list' ); + + foreach ( $milestones as $milestone ) { + $pull_requests = GitHub::get_project_milestone_pull_requests( $package_name, $milestone->number ); $repo_contributors = GitHub::parse_contributors_from_pull_requests( $pull_requests ); - WP_CLI::log( ' - Contributors: ' . count( $repo_contributors ) ); - WP_CLI::log( ' - Pull requests: ' . count( $pull_requests ) ); + WP_CLI::debug( count( $repo_contributors ) . ' contributors, ' . count( $pull_requests ) . " pull requests in '{$package_name}' milestone '{$milestone->title}'", 'contrib-list' ); $pull_request_count += count( $pull_requests ); $contributors = array_merge( $contributors, $repo_contributors ); } diff --git a/.maintenance/src/Release_Notes_Command.php b/.maintenance/src/Release_Notes_Command.php index 1b92f6e..f4b5cb7 100644 --- a/.maintenance/src/Release_Notes_Command.php +++ b/.maintenance/src/Release_Notes_Command.php @@ -19,6 +19,19 @@ final class Release_Notes_Command { * : Name of one or more milestones to fetch the release notes for. If none * are passed, the current open one is assumed. * + * [--release=] + * : Version of the bundle release, e.g. 3.0.0. Uses the milestone with + * that title in wp-cli/wp-cli-bundle, wp-cli/wp-cli and wp-cli/handbook + * instead of the currently open one, and reads the bundled packages from + * the composer.lock at the release tag of wp-cli/wp-cli-bundle. Only + * applies when no repo is passed. + * + * [--bundle-ref=] + * : Branch, tag or commit of wp-cli/wp-cli-bundle whose composer.lock lists + * the packages and versions shipped in this release. Defaults to the + * release tag when --release is passed and the tag exists, and to the + * default branch otherwise. Only applies when no repo is passed. + * * [--source=] * : Choose source from where to copy content. * --- @@ -48,8 +61,10 @@ public function __invoke( $args, $assoc_args ) { $milestone_names = $args; - $source = Utils\get_flag_value( $assoc_args, 'source', 'release' ); - $format = Utils\get_flag_value( $assoc_args, 'format', 'markdown' ); + $source = Utils\get_flag_value( $assoc_args, 'source', 'release' ); + $format = Utils\get_flag_value( $assoc_args, 'format', 'markdown' ); + $release = Utils\get_flag_value( $assoc_args, 'release' ); + $bundle_ref = Utils\get_flag_value( $assoc_args, 'bundle-ref' ); if ( $repo ) { $this->get_repo_release_notes( @@ -62,28 +77,13 @@ public function __invoke( $args, $assoc_args ) { return; } - $this->get_bundle_release_notes( $source, $format ); + $this->get_bundle_release_notes( $source, $format, $release, $bundle_ref ); } - private function get_bundle_release_notes( $source, $format ) { - // Get the release notes for the lowest open project milestones. - foreach ( - array( - 'wp-cli/wp-cli-bundle', - 'wp-cli/wp-cli', - 'wp-cli/handbook', - ) as $repo - ) { - $milestones = GitHub::get_project_milestones( $repo ); - $milestone = array_reduce( - $milestones, - static function ( $latest, $milestone ) { - if ( null === $latest ) { - return $milestone; - } - return version_compare( $milestone->title, $latest->title, '<' ) ? $milestone : $latest; - } - ); + private function get_bundle_release_notes( $source, $format, $release, $bundle_ref ) { + // Get the release notes for the milestones of the release repositories. + foreach ( Bundle::RELEASE_REPOS as $repo ) { + $milestone = Bundle::get_release_milestone( $repo, $release ); if ( ! $milestone ) { WP_CLI::debug( "No milestone found for repo '{$repo}'", 'release-notes' ); @@ -103,84 +103,27 @@ static function ( $latest, $milestone ) { } // Identify all command dependencies and their release notes + list( $ref, $lock ) = Bundle::get_release_lock( $release, $bundle_ref ); + list( $previous_ref, $previous_lock ) = Bundle::get_previous_release_lock( $release ); - $bundle = 'wp-cli/wp-cli-bundle'; - - $milestones = GitHub::get_project_milestones( - $bundle, - array( 'state' => 'closed' ) - ); + WP_CLI::debug( "Bundled packages read from wp-cli/wp-cli-bundle@{$ref}, previous release: " . ( $previous_ref ?: 'none' ), 'release-notes' ); - $milestone = array_reduce( - $milestones, - function ( $tag, $milestone ) { - if ( ! $tag ) { - return $milestone->title; - } - return version_compare( $milestone->title, $tag, '>' ) ? $milestone->title : $tag; - } - ); + $previous_versions = $previous_lock ? Bundle::get_packages( $previous_lock ) : []; - $tag = ! empty( $milestone ) ? "v{$milestone}" : GitHub::get_default_branch( $bundle ); + foreach ( Bundle::get_packages( $lock ) as $package_name => $version ) { + $previous_version = isset( $previous_versions[ $package_name ] ) ? $previous_versions[ $package_name ] : null; - $composer_lock_url = sprintf( - 'https://raw.githubusercontent.com/%s/%s/composer.lock', - $bundle, - $tag - ); - $response = Utils\http_request( 'GET', $composer_lock_url ); - if ( 200 !== $response->status_code ) { - WP_CLI::error( - sprintf( - 'Could not fetch composer.json (HTTP code %d)', - $response->status_code - ) - ); - } - $composer_json = json_decode( $response->body, true ); - - usort( - $composer_json['packages'], - function ( $a, $b ) { - return $a['name'] < $b['name'] ? - 1 : 1; - } - ); + // Closed milestones denote a tagged release + $milestones = Bundle::get_shipped_milestones( $package_name, $previous_version, $version ); - foreach ( $composer_json['packages'] as $package ) { - $package_name = $package['name']; - $version_constraint = str_replace( 'v', '', $package['version'] ); - if ( ! preg_match( '#^wp-cli/.+-command$#', $package_name ) - && ! in_array( - $package_name, - array( - 'wp-cli/wp-cli-tests', - 'wp-cli/regenerate-readme', - 'wp-cli/autoload-splitter', - 'wp-cli/wp-config-transformer', - 'wp-cli/php-cli-tools', - 'wp-cli/spyc', - ), - true - ) ) { + if ( empty( $milestones ) ) { + WP_CLI::debug( "No releases of '{$package_name}' shipped since " . ( $previous_ref ?: 'the beginning' ), 'release-notes' ); continue; } WP_CLI::log( $this->repo_heading( $package_name, $format ) ); - // Closed milestones denote a tagged release - $milestones = GitHub::get_project_milestones( - $package_name, - array( 'state' => 'closed' ) - ); foreach ( $milestones as $milestone ) { - if ( ! version_compare( - $milestone->title, - $version_constraint, - '>' - ) ) { - continue; - } - $this->get_repo_release_notes( $package_name, $milestone->title, diff --git a/README.md b/README.md index b594dcf..1fa9633 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ wp maintenance Lists all contributors to this release. ~~~ -wp maintenance contrib-list [] [...] [--format=] +wp maintenance contrib-list [] [...] [--release=] [--bundle-ref=] [--format=] ~~~ Run within the main WP-CLI project repository. @@ -80,6 +80,19 @@ Run within the main WP-CLI project repository. Name of one or more milestones to fetch the release notes for. If none are passed, the current open one is assumed. + [--release=] + Version of the bundle release, e.g. 3.0.0. Uses the milestone with + that title in wp-cli/wp-cli-bundle, wp-cli/wp-cli and wp-cli/handbook + instead of the currently open one, and reads the bundled packages from + the composer.lock at the release tag of wp-cli/wp-cli-bundle. Only + applies when no repo is passed. + + [--bundle-ref=] + Branch, tag or commit of wp-cli/wp-cli-bundle whose composer.lock lists + the packages and versions shipped in this release. Defaults to the + release tag when --release is passed and the tag exists, and to the + default branch otherwise. Only applies when no repo is passed. + [--format=] Render output in a specific format. --- @@ -153,7 +166,7 @@ wp maintenance release-date Gets the release notes for one or more milestones of a repository. ~~~ -wp maintenance release-notes [] [...] [--source=] [--format=] +wp maintenance release-notes [] [...] [--release=] [--bundle-ref=] [--source=] [--format=] ~~~ **OPTIONS** @@ -167,6 +180,19 @@ wp maintenance release-notes [] [...] [--source=] [--fo Name of one or more milestones to fetch the release notes for. If none are passed, the current open one is assumed. + [--release=] + Version of the bundle release, e.g. 3.0.0. Uses the milestone with + that title in wp-cli/wp-cli-bundle, wp-cli/wp-cli and wp-cli/handbook + instead of the currently open one, and reads the bundled packages from + the composer.lock at the release tag of wp-cli/wp-cli-bundle. Only + applies when no repo is passed. + + [--bundle-ref=] + Branch, tag or commit of wp-cli/wp-cli-bundle whose composer.lock lists + the packages and versions shipped in this release. Defaults to the + release tag when --release is passed and the tag exists, and to the + default branch otherwise. Only applies when no repo is passed. + [--source=] Choose source from where to copy content. --- From bc43acaee6942d7cc0fa69ce3b3166987559b900 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 15 Sep 2026 09:42:03 +0000 Subject: [PATCH 65/72] Harden the bundle lookups and cover them with unit tests Review follow-ups on #76: - Only a 404 counts as "the tag has no lockfile yet". Any other failure (rate limit, outage) is fatal, so a bad response can no longer make the commands silently fall back to the default branch. - Milestone titles that are not versions are ignored when picking the previous bundle release, not just when filtering package milestones, so a stray closed milestone cannot turn into a bogus tag. The version check is anchored and accepts a pre-release suffix. - The Spyc package is `wp-cli/mustangostang-spyc`, which is the name in the bundle lock; `wp-cli/spyc` never matched anything. - `close-released --bundle` now reads the same package list, which also brings in `packages-dev` (package-command, wp-cli-tests). The selection logic is split into pure functions (`find_previous_release`, `filter_shipped_milestones`, `is_version`) with PHPUnit coverage; the org testing workflow picks the tests up through phpunit.xml.dist. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016VDHkafhoHtgAPuYkvgvqh --- .github/workflows/testing.yml | 12 ++ .gitignore | 1 + .maintenance/src/Bundle.php | 94 +++++++++---- .maintenance/src/Release_Command.php | 38 +----- phpunit.xml.dist | 26 ++++ tests/BundleTest.php | 194 +++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/testing.yml create mode 100644 phpunit.xml.dist create mode 100644 tests/BundleTest.php diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..316113b --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,12 @@ +name: Testing + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + test: + uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main diff --git a/.gitignore b/.gitignore index 11b884e..b511fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ !/.github/ !/.maintenance/ !/.readme-partials/ +!/tests/ composer.lock *.log diff --git a/.maintenance/src/Bundle.php b/.maintenance/src/Bundle.php index 109fe2d..8d7862b 100644 --- a/.maintenance/src/Bundle.php +++ b/.maintenance/src/Bundle.php @@ -29,7 +29,7 @@ final class Bundle { 'wp-cli/autoload-splitter', 'wp-cli/wp-config-transformer', 'wp-cli/php-cli-tools', - 'wp-cli/spyc', + 'wp-cli/mustangostang-spyc', ]; /** @@ -112,12 +112,42 @@ public static function get_release_lock( $release = null, $bundle_ref = null ) { * previous release. */ public static function get_previous_release_lock( $release = null ) { + $previous = self::find_previous_release( + GitHub::get_project_milestones( self::REPO, [ 'state' => 'closed' ] ), + $release + ); + + if ( null === $previous ) { + return [ null, null ]; + } + + $tag = "v{$previous}"; + + return [ $tag, self::get_lock( $tag ) ]; + } + + /** + * Picks the highest release version below the given one out of a list of + * milestones. Milestones whose title is not a version are ignored. + * + * @param array $milestones Milestone objects with a `title`. + * @param string|null $release Bundle release version, e.g. '3.0.0'. + * Without it, the highest version wins. + * + * @return string|null Version without leading 'v', or null when there is + * no release below the given one. + */ + public static function find_previous_release( array $milestones, $release = null ) { $release = $release ? ltrim( $release, 'v' ) : null; $previous = null; - foreach ( GitHub::get_project_milestones( self::REPO, [ 'state' => 'closed' ] ) as $milestone ) { + foreach ( $milestones as $milestone ) { $title = ltrim( $milestone->title, 'v' ); + if ( ! self::is_version( $title ) ) { + continue; + } + if ( $release && ! version_compare( $title, $release, '<' ) ) { continue; } @@ -127,32 +157,30 @@ public static function get_previous_release_lock( $release = null ) { } } - if ( null === $previous ) { - return [ null, null ]; - } - - $tag = "v{$previous}"; - - return [ $tag, self::get_lock( $tag ) ]; + return $previous; } /** * Fetches and decodes the composer.lock of wp-cli/wp-cli-bundle at a ref. * - * @param string $ref Branch, tag or commit. - * @param bool $throw_errors Whether a missing lockfile is fatal. + * @param string $ref Branch, tag or commit. + * @param bool $missing_is_fatal Whether a ref without a lockfile (HTTP + * 404) is fatal. Any other failure always + * is, so that a rate limit or an outage + * never passes for a missing tag. * - * @return array|false + * @return array|false Decoded lockfile, or false when the ref has none + * and that is not fatal. */ - public static function get_lock( $ref, $throw_errors = true ) { + public static function get_lock( $ref, $missing_is_fatal = true ) { $url = sprintf( 'https://raw.githubusercontent.com/%s/%s/composer.lock', self::REPO, $ref ); $response = Utils\http_request( 'GET', $url ); - if ( 200 !== $response->status_code ) { - if ( ! $throw_errors ) { - return false; - } + if ( 404 === (int) $response->status_code && ! $missing_is_fatal ) { + return false; + } + if ( 200 !== (int) $response->status_code ) { WP_CLI::error( sprintf( 'Could not fetch %s (HTTP code %d)', $url, $response->status_code ) ); } @@ -202,24 +230,41 @@ public static function get_packages( array $lock ) { * @return array */ public static function get_shipped_milestones( $package, $previous_version, $current_version ) { - $milestones = GitHub::get_project_milestones( $package, [ 'state' => 'closed' ] ); + return self::filter_shipped_milestones( + GitHub::get_project_milestones( $package, [ 'state' => 'closed' ] ), + $previous_version, + $current_version + ); + } + /** + * Keeps the milestones whose version lies in (previous, current]. A + * version that cannot be compared, such as `dev-main`, does not bound; + * milestones whose title is not a version are dropped. + * + * @param array $milestones Milestone objects with a `title`. + * @param string|null $previous_version Version locked by the previous release. + * @param string|null $current_version Version locked by this release. + * + * @return array + */ + public static function filter_shipped_milestones( array $milestones, $previous_version, $current_version ) { return array_values( array_filter( $milestones, static function ( $milestone ) use ( $previous_version, $current_version ) { $title = ltrim( $milestone->title, 'v' ); - if ( ! self::is_numeric_version( $title ) ) { + if ( ! self::is_version( $title ) ) { return false; } - if ( self::is_numeric_version( $previous_version ) + if ( self::is_version( $previous_version ) && ! version_compare( $title, $previous_version, '>' ) ) { return false; } - if ( self::is_numeric_version( $current_version ) + if ( self::is_version( $current_version ) && version_compare( $title, $current_version, '>' ) ) { return false; } @@ -244,13 +289,14 @@ public static function is_bundled_package( $name ) { } /** - * Checks whether a version can be compared, as opposed to `dev-main`. + * Checks whether a string is a version that can be compared, such as + * `2.12.0` or `3.0.0-beta1`, as opposed to `dev-main` or `3.0.0 (docs)`. * * @param string|null $version Version string, or null when unknown. * * @return bool */ - private static function is_numeric_version( $version ) { - return null !== $version && (bool) preg_match( '/^\d+(\.\d+)*/', $version ); + public static function is_version( $version ) { + return null !== $version && (bool) preg_match( '/^\d+(\.\d+)*(-[0-9A-Za-z.]+)?$/', $version ); } } diff --git a/.maintenance/src/Release_Command.php b/.maintenance/src/Release_Command.php index d6981cd..8199fb8 100644 --- a/.maintenance/src/Release_Command.php +++ b/.maintenance/src/Release_Command.php @@ -289,42 +289,8 @@ static function ( $repo ) use ( $exclude ) { } private function get_bundle_repos() { - $repos = []; - $default_branch = GitHub::get_default_branch( 'wp-cli/wp-cli-bundle' ); - $composer_lock_url = "https://raw.githubusercontent.com/wp-cli/wp-cli-bundle/{$default_branch}/composer.lock"; - $response = Utils\http_request( 'GET', $composer_lock_url ); - if ( 200 !== $response->status_code ) { - WP_CLI::error( sprintf( 'Could not fetch composer.json (HTTP code %d)', $response->status_code ) ); - } - $composer_json = json_decode( $response->body, true ); - - usort( - $composer_json['packages'], - static function ( $a, $b ) { - return $a['name'] < $b['name'] ? - 1 : 1; - } - ); - - foreach ( $composer_json['packages'] as $package ) { - $package_name = $package['name']; - if ( ! preg_match( '#^wp-cli/.+-command$#', $package_name ) - && ! in_array( - $package_name, - array( - 'wp-cli/wp-cli-tests', - 'wp-cli/regenerate-readme', - 'wp-cli/autoload-splitter', - 'wp-cli/wp-config-transformer', - 'wp-cli/php-cli-tools', - 'wp-cli/spyc', - ), - true - ) ) { - continue; - } - $repos[] = $package_name; - } + $default_branch = GitHub::get_default_branch( Bundle::REPO ); - return $repos; + return array_keys( Bundle::get_packages( Bundle::get_lock( $default_branch ) ) ); } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d82f16d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + tests + + + + + + .maintenance/src + + + diff --git a/tests/BundleTest.php b/tests/BundleTest.php new file mode 100644 index 0000000..2ad7b16 --- /dev/null +++ b/tests/BundleTest.php @@ -0,0 +1,194 @@ + $title ]; + }, + $titles + ); + } + + /** + * @param object[] $milestones Milestone objects. + * + * @return string[] + */ + private function titles( array $milestones ) { + return array_map( + static function ( $milestone ) { + return $milestone->title; + }, + $milestones + ); + } + + public function test_is_version_accepts_comparable_versions(): void { + $this->assertTrue( Bundle::is_version( '2.12.0' ) ); + $this->assertTrue( Bundle::is_version( '3.0' ) ); + $this->assertTrue( Bundle::is_version( '3.0.0-beta1' ) ); + $this->assertTrue( Bundle::is_version( '3.0.0-rc.2' ) ); + } + + public function test_is_version_rejects_everything_else(): void { + $this->assertFalse( Bundle::is_version( null ) ); + $this->assertFalse( Bundle::is_version( '' ) ); + $this->assertFalse( Bundle::is_version( 'dev-main' ) ); + $this->assertFalse( Bundle::is_version( 'v3.0.0' ) ); + $this->assertFalse( Bundle::is_version( '3.0.0 (docs)' ) ); + $this->assertFalse( Bundle::is_version( 'Future' ) ); + } + + public function test_is_bundled_package_matches_commands_and_known_libraries(): void { + $this->assertTrue( Bundle::is_bundled_package( 'wp-cli/cache-command' ) ); + $this->assertTrue( Bundle::is_bundled_package( 'wp-cli/package-command' ) ); + $this->assertTrue( Bundle::is_bundled_package( 'wp-cli/php-cli-tools' ) ); + $this->assertTrue( Bundle::is_bundled_package( 'wp-cli/mustangostang-spyc' ) ); + $this->assertTrue( Bundle::is_bundled_package( 'wp-cli/wp-cli-tests' ) ); + $this->assertFalse( Bundle::is_bundled_package( 'wp-cli/wp-cli' ) ); + $this->assertFalse( Bundle::is_bundled_package( 'wp-cli/wp-cli-bundle' ) ); + $this->assertFalse( Bundle::is_bundled_package( 'wp-cli/process' ) ); + $this->assertFalse( Bundle::is_bundled_package( 'symfony/finder' ) ); + } + + public function test_get_packages_reads_both_sections_and_strips_the_v(): void { + $lock = [ + 'packages' => [ + [ + 'name' => 'wp-cli/wp-cli', + 'version' => 'dev-main', + ], + [ + 'name' => 'wp-cli/site-health-command', + 'version' => 'v1.0.0', + ], + [ + 'name' => 'wp-cli/cache-command', + 'version' => 'v2.1.3', + ], + [ + 'name' => 'wp-cli/mustangostang-spyc', + 'version' => '0.6.3', + ], + [ + 'name' => 'symfony/finder', + 'version' => 'v5.4.0', + ], + ], + 'packages-dev' => [ + [ + 'name' => 'wp-cli/package-command', + 'version' => 'v2.5.0', + ], + [ + 'name' => 'wp-cli/wp-cli-tests', + 'version' => 'v5.0.0', + ], + [ + 'name' => 'phpunit/phpunit', + 'version' => '9.6.0', + ], + ], + ]; + + $this->assertSame( + [ + 'wp-cli/cache-command' => '2.1.3', + 'wp-cli/mustangostang-spyc' => '0.6.3', + 'wp-cli/package-command' => '2.5.0', + 'wp-cli/site-health-command' => '1.0.0', + 'wp-cli/wp-cli-tests' => '5.0.0', + ], + Bundle::get_packages( $lock ) + ); + } + + public function test_get_packages_tolerates_a_lock_without_dev_packages(): void { + $lock = [ + 'packages' => [ + [ + 'name' => 'wp-cli/cache-command', + 'version' => 'v2.1.3', + ], + ], + ]; + + $this->assertSame( [ 'wp-cli/cache-command' => '2.1.3' ], Bundle::get_packages( $lock ) ); + } + + public function test_find_previous_release_picks_the_highest_version_below_the_release(): void { + $milestones = $this->milestones( [ '2.9.0', '2.12.0', '2.10.0', '3.0.0', '2.11.0' ] ); + + $this->assertSame( '2.12.0', Bundle::find_previous_release( $milestones, '3.0.0' ) ); + $this->assertSame( '2.12.0', Bundle::find_previous_release( $milestones, 'v3.0.0' ) ); + $this->assertSame( '2.10.0', Bundle::find_previous_release( $milestones, '2.11.0' ) ); + } + + public function test_find_previous_release_without_a_release_picks_the_highest_version(): void { + $milestones = $this->milestones( [ '2.9.0', '3.0.0', '2.12.0' ] ); + + $this->assertSame( '3.0.0', Bundle::find_previous_release( $milestones ) ); + } + + public function test_find_previous_release_ignores_titles_that_are_not_versions(): void { + $milestones = $this->milestones( [ '2.12.0', 'Future', '3.0.0 (docs)', 'v2.11.0' ] ); + + $this->assertSame( '2.12.0', Bundle::find_previous_release( $milestones, '3.0.0' ) ); + $this->assertNull( Bundle::find_previous_release( $this->milestones( [ 'Future' ] ), '3.0.0' ) ); + } + + public function test_find_previous_release_returns_null_below_the_first_release(): void { + $this->assertNull( Bundle::find_previous_release( $this->milestones( [ '3.0.0', '3.1.0' ] ), '3.0.0' ) ); + $this->assertNull( Bundle::find_previous_release( [], '3.0.0' ) ); + } + + public function test_filter_shipped_milestones_keeps_versions_between_previous_and_current(): void { + $milestones = $this->milestones( [ '2.1.0', '2.1.1', 'v2.1.2', '2.1.3', '2.2.0' ] ); + + $this->assertSame( + [ '2.1.1', 'v2.1.2', '2.1.3' ], + $this->titles( Bundle::filter_shipped_milestones( $milestones, '2.1.0', '2.1.3' ) ) + ); + } + + public function test_filter_shipped_milestones_includes_everything_for_a_newly_bundled_package(): void { + $milestones = $this->milestones( [ '0.9.0', '1.0.0', '1.1.0' ] ); + + $this->assertSame( + [ '0.9.0', '1.0.0' ], + $this->titles( Bundle::filter_shipped_milestones( $milestones, null, '1.0.0' ) ) + ); + } + + public function test_filter_shipped_milestones_does_not_bound_on_dev_versions(): void { + $milestones = $this->milestones( [ '2.1.0', '2.1.1', '2.2.0' ] ); + + $this->assertSame( + [ '2.1.1', '2.2.0' ], + $this->titles( Bundle::filter_shipped_milestones( $milestones, '2.1.0', 'dev-main' ) ) + ); + $this->assertSame( + [ '2.1.0', '2.1.1' ], + $this->titles( Bundle::filter_shipped_milestones( $milestones, 'dev-main', '2.1.1' ) ) + ); + } + + public function test_filter_shipped_milestones_drops_titles_that_are_not_versions(): void { + $milestones = $this->milestones( [ '2.1.1', 'Future', '2.1.2 (docs)', '.org' ] ); + + $this->assertSame( + [ '2.1.1' ], + $this->titles( Bundle::filter_shipped_milestones( $milestones, '2.1.0', '2.1.3' ) ) + ); + } +} From 433a20013e3452b6f901262c09fd4f74d1e54a6d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 15 Sep 2026 09:48:16 +0000 Subject: [PATCH 66/72] Run unit tests from PHP 7.4 up The development environment cannot be installed below PHP 7.4: johnpbloch/wordpress-core dev-master requires it, so `composer install` fails on the 7.2 and 7.3 legs of the default matrix before any test runs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016VDHkafhoHtgAPuYkvgvqh --- .github/workflows/testing.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 316113b..da8d53c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -10,3 +10,8 @@ on: jobs: test: uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main + with: + # composer.json still allows PHP 7.2, but the development environment + # cannot be installed below 7.4: johnpbloch/wordpress-core dev-master + # requires it, so `composer install` fails on the 7.2 and 7.3 legs. + minimum-php: '7.4' From 78300a12a3200899f4697a73391b8f6c530428eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Sep 2026 11:35:37 +0000 Subject: [PATCH 67/72] Regenerate README file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1fa9633..fc7bbf4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Sets up a WP-CLI development environment that allows for easy development across This allows easy development across all packages and contains additional maintenance commands that simplify repository chores and the release process. - +[![Testing](https://github.com/wp-cli/wp-cli-dev/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/wp-cli-dev/actions/workflows/testing.yml) Quick links: [Installation](#installation) | [Development](#development) | [Using](#using) | [Contributing](#contributing) | [Support](#support) From 40115b287fdc9b22f4e39e14acbe11e2caf494c8 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Thu, 17 Sep 2026 06:11:51 +0000 Subject: [PATCH 68/72] Update file(s) from "wp-cli/.github" --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index f2541f7..186dd3c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -65,7 +65,7 @@ jobs: - name: Setup MySQL Server if: steps.check_behat_file.outputs.files_exists == 'true' - uses: shogo82148/actions-setup-mysql@62da9377d83991fce27b6ed2a397d3306121e41d # v1 + uses: shogo82148/actions-setup-mysql@083ec148d28e62f2b24b8f4541693dfe1d50488d # v1 with: mysql-version: '8.0' # Standard MySQL version for these tests auto-start: true From 6d54ab99a1bc617bd4a999aac57f995000dbf809 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Mon, 21 Sep 2026 11:05:51 +0000 Subject: [PATCH 69/72] Update file(s) from "wp-cli/.github" --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 991c6c3..49fbe19 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,9 @@ updates: - scope:distribution cooldown: default-days: 7 + # Packages from our own org are trusted, so they are updated immediately. + exclude: + - "wp-cli/*" # Only wp-cli-tests carries a package.json, holding the pinned version of the # Gherkin linter. This is a no-op in every other repository. - package-ecosystem: npm From e0eb9dd59f9fe82efa26348f3100377d941ca803 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Tue, 6 Oct 2026 06:18:21 +0000 Subject: [PATCH 70/72] Update file(s) from "wp-cli/.github" --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 186dd3c..5710119 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -65,7 +65,7 @@ jobs: - name: Setup MySQL Server if: steps.check_behat_file.outputs.files_exists == 'true' - uses: shogo82148/actions-setup-mysql@083ec148d28e62f2b24b8f4541693dfe1d50488d # v1 + uses: shogo82148/actions-setup-mysql@334a29f22d90428932f75b11e0d5fcc00bb6b4f4 # v1 with: mysql-version: '8.0' # Standard MySQL version for these tests auto-start: true From b1c50672044dacbe1148d4e4d86f2d606c2ddb79 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Wed, 7 Oct 2026 11:31:23 +0000 Subject: [PATCH 71/72] Update file(s) from "wp-cli/.github" --- .github/actionlint.yml | 9 ++++++ .github/workflows/copilot-setup-steps.yml | 39 +++++++++++++---------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.github/actionlint.yml b/.github/actionlint.yml index 813317a..e2f934a 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -11,3 +11,12 @@ paths: # catching genuine typos in permission names. # See https://docs.github.com/en/copilot/how-tos/copilot-cli/use-copilot-cli-in-actions - 'unknown permission scope "copilot-requests"' + # Steps can run in parallel through the `background`, `wait`, `wait-all`, + # `cancel` and `parallel` keywords, but actionlint does not know them yet: + # it rejects `background` as an unexpected key, and a `wait-all` or + # `parallel` step as one that has neither `run` nor `uses`. Drop these + # entries once actionlint supports the keywords. Until then, a step that is + # genuinely missing both `run` and `uses` is no longer reported. + # See https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsparallel + - 'unexpected key "background" for step' + - 'step must run script with "run" section or run action with "uses" section' diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 5710119..92579d8 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -39,6 +39,27 @@ jobs: id: check_composer_file run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT" + # Without a database the Behat runner silently falls back to SQLite, so the + # agent's test runs are not the ones CI will do. Mirrors the setup in + # reusable-functional.yml. + - name: Check existence of behat.yml file + id: check_behat_file + run: echo "files_exists=$(test -f behat.yml && echo true || echo false)" >> "$GITHUB_OUTPUT" + + # Downloading and starting the database server is the slowest part of the + # setup and it is independent of PHP and Composer, so it runs in the + # background and is waited for right before the test database is prepared. + - name: Setup MySQL Server + if: steps.check_behat_file.outputs.files_exists == 'true' + background: true + uses: shogo82148/actions-setup-mysql@334a29f22d90428932f75b11e0d5fcc00bb6b4f4 # v1 + with: + mysql-version: '8.0' # Standard MySQL version for these tests + auto-start: true + root-password: ${{ env.WP_CLI_TEST_DBROOTPASS }} + user: ${{ env.WP_CLI_TEST_DBUSER }} + password: ${{ env.WP_CLI_TEST_DBPASS }} + - name: Set up PHP environment if: steps.check_composer_file.outputs.files_exists == 'true' uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -56,22 +77,8 @@ jobs: env: COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} - # Without a database the Behat runner silently falls back to SQLite, so the - # agent's test runs are not the ones CI will do. Mirrors the setup in - # reusable-functional.yml. - - name: Check existence of behat.yml file - id: check_behat_file - run: echo "files_exists=$(test -f behat.yml && echo true || echo false)" >> "$GITHUB_OUTPUT" - - - name: Setup MySQL Server - if: steps.check_behat_file.outputs.files_exists == 'true' - uses: shogo82148/actions-setup-mysql@334a29f22d90428932f75b11e0d5fcc00bb6b4f4 # v1 - with: - mysql-version: '8.0' # Standard MySQL version for these tests - auto-start: true - root-password: ${{ env.WP_CLI_TEST_DBROOTPASS }} - user: ${{ env.WP_CLI_TEST_DBUSER }} - password: ${{ env.WP_CLI_TEST_DBPASS }} + - name: Wait for the MySQL server + wait-all: - name: Remove system MySQL binary if: steps.check_behat_file.outputs.files_exists == 'true' From 3a77a0643295f5548058a40556e0503c3de9e4f1 Mon Sep 17 00:00:00 2001 From: swissspidy Date: Thu, 8 Oct 2026 05:47:49 +0000 Subject: [PATCH 72/72] Update file(s) from "wp-cli/.github" --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 92579d8..5d79e8a 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -52,7 +52,7 @@ jobs: - name: Setup MySQL Server if: steps.check_behat_file.outputs.files_exists == 'true' background: true - uses: shogo82148/actions-setup-mysql@334a29f22d90428932f75b11e0d5fcc00bb6b4f4 # v1 + uses: shogo82148/actions-setup-mysql@1d4ebc60ba1227dd13fc7e6ba845bd3997928d75 # v1 with: mysql-version: '8.0' # Standard MySQL version for these tests auto-start: true