Commit 2efdf3b3688 for woocommerce
commit 2efdf3b3688645fc2291db7bfd2bde70b6e08573
Author: Alba Rincón <albarin@users.noreply.github.com>
Date: Tue May 19 12:08:01 2026 +0200
Add CI check for GraphQL API staleness (#64803)
* Add CI workflow for GraphQL API staleness check
* Switch staleness check to build-and-diff
* Switch staleness check to content hashing
* Regenerate API source hash after trunk merge
* Pin phpcbf to WordPress-Core in API builder
diff --git a/.github/workflows/api-staleness.yml b/.github/workflows/api-staleness.yml
new file mode 100644
index 00000000000..25752dbbb96
--- /dev/null
+++ b/.github/workflows/api-staleness.yml
@@ -0,0 +1,40 @@
+name: 'GraphQL API Staleness Check'
+
+on:
+ pull_request:
+ paths:
+ - 'plugins/woocommerce/src/Api/**'
+ - 'plugins/woocommerce/src/Internal/Api/Autogenerated/**'
+ - 'plugins/woocommerce/src/Internal/Api/DesignTime/**'
+ - '.github/workflows/api-staleness.yml'
+ push:
+ paths:
+ - 'plugins/woocommerce/src/Api/**'
+ - 'plugins/woocommerce/src/Internal/Api/Autogenerated/**'
+ - 'plugins/woocommerce/src/Internal/Api/DesignTime/**'
+ - '.github/workflows/api-staleness.yml'
+ branches:
+ - 'trunk'
+ - 'release/*'
+
+concurrency:
+ group: api-staleness-${{ github.event_name == 'push' && github.run_id || github.event_name }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ api-staleness:
+ name: 'GraphQL API Staleness Check'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: 'actions/checkout@v4'
+ name: 'Checkout'
+
+ - uses: './.github/actions/setup-woocommerce-monorepo'
+ id: 'setup-monorepo'
+ with:
+ install: '@woocommerce/plugin-woocommerce...'
+ pull-package-deps: '@woocommerce/plugin-woocommerce'
+ php-version: '8.4'
+
+ - name: 'Check GraphQL API staleness'
+ run: pnpm --filter='@woocommerce/plugin-woocommerce' build:api:check
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index b1c719ab326..c506a3be2b1 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-12T08:46:18+00:00
\ No newline at end of file
+2026-05-14T09:10:45+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
new file mode 100644
index 00000000000..abfaa569da0
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -0,0 +1 @@
+effb4e07b32a1d12897204e7e092d029b51b74f62f150912c7f13ba4aea9d2cf
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
index b29dfd266e3..8aa5abe8237 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -2248,6 +2248,10 @@ class ApiBuilder {
$this->autogenerated_dir . '/api_generation_date.txt',
gmdate( 'c' )
);
+ file_put_contents(
+ $this->autogenerated_dir . '/api_source_hash.txt',
+ StalenessChecker::compute_source_hash( $this->api_dir )
+ );
}
private function render_template( string $template_name, array $vars ): string {
@@ -2783,7 +2787,11 @@ class ApiBuilder {
}
private function format_with_phpcbf( string $file_path ): void {
- exec( escapeshellarg( $this->phpcbf_path ) . ' -q ' . escapeshellarg( $file_path ) . ' 2>&1' );
+ // Pass --standard explicitly: without it phpcbf reads the project's
+ // phpcs.xml, whose Suin.Classes.PSR4 sniff aborts processing under
+ // PHP 8.x with a "${var} in strings is deprecated" notice, leaving
+ // the generated files unformatted.
+ exec( escapeshellarg( $this->phpcbf_path ) . ' -q --standard=WordPress-Core ' . escapeshellarg( $file_path ) . ' 2>&1' );
}
private function rmdir_recursive( string $dir ): void {
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
index 4a535f0ef05..4e8b4d65dfa 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/StalenessChecker.php
@@ -6,31 +6,80 @@ namespace Automattic\WooCommerce\Internal\Api\DesignTime\Scripts;
/**
* Checks whether the autogenerated API code is stale and needs rebuilding.
+ *
+ * Staleness is determined by hashing the contents of the code-API sources and
+ * comparing the result to a hash recorded by {@see ApiBuilder} the last time
+ * it ran. Using content hashes (rather than file mtimes) keeps the check
+ * reliable in environments where mtimes don't reflect actual edit history,
+ * such as fresh `git clone` checkouts and CI runners.
*/
class StalenessChecker {
+ private const HASH_FILE_NAME = 'api_source_hash.txt';
+
/**
* Returns true if the autogenerated code is stale (needs rebuilding).
+ *
+ * @param string|null $api_dir Absolute path to the code-API sources. Defaults to WooCommerce core's `src/Api`.
+ * @param string|null $autogenerated_dir Absolute path to the autogenerated output directory. Defaults to WooCommerce core's `src/Internal/Api/Autogenerated`.
*/
- public static function is_stale(): bool {
- $timestamp_file = __DIR__ . '/../../Autogenerated/api_generation_date.txt';
+ public static function is_stale( ?string $api_dir = null, ?string $autogenerated_dir = null ): bool {
+ $api_dir ??= __DIR__ . '/../../../../Api';
+ $autogenerated_dir ??= __DIR__ . '/../../Autogenerated';
- if ( ! file_exists( $timestamp_file ) ) {
+ $hash_file = $autogenerated_dir . '/' . self::HASH_FILE_NAME;
+ if ( ! file_exists( $hash_file ) ) {
return true;
}
- $generation_time = strtotime( file_get_contents( $timestamp_file ) );
- $api_dir = __DIR__ . '/../../../../Api';
+ $stored_hash = trim( (string) file_get_contents( $hash_file ) );
+ return $stored_hash !== self::compute_source_hash( $api_dir );
+ }
+
+ /**
+ * Returns a deterministic SHA-256 hash of every `.php` file under $api_dir.
+ *
+ * Files are sorted by their path relative to $api_dir before hashing so
+ * the result does not depend on filesystem iteration order.
+ *
+ * @param string $api_dir Absolute path to the code-API sources directory.
+ */
+ public static function compute_source_hash( string $api_dir ): string {
+ $files = self::collect_php_files( $api_dir );
+ $hasher = hash_init( 'sha256' );
+ foreach ( $files as $relative_path => $absolute_path ) {
+ hash_update( $hasher, $relative_path );
+ hash_update( $hasher, "\0" );
+ hash_update_file( $hasher, $absolute_path );
+ hash_update( $hasher, "\0" );
+ }
+ return hash_final( $hasher );
+ }
+
+ /**
+ * Returns an associative array of relative path => absolute path for every
+ * `.php` file under $dir, sorted by relative path.
+ *
+ * @return array<string, string>
+ */
+ private static function collect_php_files( string $dir ): array {
+ $files = array();
+
+ if ( ! is_dir( $dir ) ) {
+ return $files;
+ }
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator( $api_dir, \FilesystemIterator::SKIP_DOTS )
+ $prefix_length = strlen( $dir ) + 1;
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS )
);
foreach ( $iterator as $file ) {
- if ( 'php' === $file->getExtension() && $file->getMTime() > $generation_time ) {
- return true;
+ if ( $file->isFile() && 'php' === $file->getExtension() ) {
+ $files[ substr( $file->getPathname(), $prefix_length ) ] = $file->getPathname();
}
}
- return false;
+ ksort( $files );
+ return $files;
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/DesignTime/Scripts/StalenessCheckerTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/DesignTime/Scripts/StalenessCheckerTest.php
new file mode 100644
index 00000000000..a25497867d5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/DesignTime/Scripts/StalenessCheckerTest.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\DesignTime\Scripts;
+
+use Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\StalenessChecker;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see StalenessChecker} — the hash-based staleness detector used
+ * by `pnpm run build:api:check` to decide whether the autogenerated GraphQL
+ * API code matches the current code-API sources.
+ */
+class StalenessCheckerTest extends WC_Unit_Test_Case {
+ private string $api_dir;
+ private string $autogen_dir;
+
+ public function setUp(): void {
+ parent::setUp();
+ $base = sys_get_temp_dir() . '/staleness-checker-' . uniqid( '', true );
+ $this->api_dir = $base . '/Api';
+ $this->autogen_dir = $base . '/Autogenerated';
+ mkdir( $this->api_dir, 0777, true );
+ mkdir( $this->autogen_dir, 0777, true );
+ file_put_contents( $this->api_dir . '/Widget.php', "<?php\nclass Widget {}\n" );
+ mkdir( $this->api_dir . '/Sub' );
+ file_put_contents( $this->api_dir . '/Sub/Nested.php', "<?php\nclass Nested {}\n" );
+ }
+
+ public function tearDown(): void {
+ $this->rmdir_recursive( dirname( $this->api_dir ) );
+ parent::tearDown();
+ }
+
+ public function test_is_stale_returns_true_when_hash_file_is_missing(): void {
+ $this->assertTrue( StalenessChecker::is_stale( $this->api_dir, $this->autogen_dir ) );
+ }
+
+ public function test_is_stale_returns_false_when_stored_hash_matches_current_sources(): void {
+ $this->write_hash_file( StalenessChecker::compute_source_hash( $this->api_dir ) );
+ $this->assertFalse( StalenessChecker::is_stale( $this->api_dir, $this->autogen_dir ) );
+ }
+
+ public function test_is_stale_returns_true_when_a_source_file_was_modified_after_the_hash_was_recorded(): void {
+ $this->write_hash_file( StalenessChecker::compute_source_hash( $this->api_dir ) );
+ file_put_contents( $this->api_dir . '/Widget.php', "<?php\nclass Widget { public int \$x = 1; }\n" );
+ $this->assertTrue( StalenessChecker::is_stale( $this->api_dir, $this->autogen_dir ) );
+ }
+
+ public function test_is_stale_returns_true_when_a_source_file_was_renamed(): void {
+ $this->write_hash_file( StalenessChecker::compute_source_hash( $this->api_dir ) );
+ rename( $this->api_dir . '/Widget.php', $this->api_dir . '/Gadget.php' );
+ $this->assertTrue( StalenessChecker::is_stale( $this->api_dir, $this->autogen_dir ) );
+ }
+
+ public function test_is_stale_returns_true_when_a_source_file_was_added(): void {
+ $this->write_hash_file( StalenessChecker::compute_source_hash( $this->api_dir ) );
+ file_put_contents( $this->api_dir . '/NewClass.php', "<?php\nclass NewClass {}\n" );
+ $this->assertTrue( StalenessChecker::is_stale( $this->api_dir, $this->autogen_dir ) );
+ }
+
+ public function test_is_stale_ignores_non_php_files(): void {
+ $this->write_hash_file( StalenessChecker::compute_source_hash( $this->api_dir ) );
+ file_put_contents( $this->api_dir . '/README.md', 'docs' );
+ $this->assertFalse( StalenessChecker::is_stale( $this->api_dir, $this->autogen_dir ) );
+ }
+
+ public function test_compute_source_hash_is_stable_across_runs(): void {
+ $first = StalenessChecker::compute_source_hash( $this->api_dir );
+ $second = StalenessChecker::compute_source_hash( $this->api_dir );
+ $this->assertSame( $first, $second );
+ }
+
+ private function write_hash_file( string $hash ): void {
+ file_put_contents( $this->autogen_dir . '/api_source_hash.txt', $hash );
+ }
+
+ private function rmdir_recursive( string $dir ): void {
+ if ( ! is_dir( $dir ) ) {
+ return;
+ }
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ( $iterator as $file ) {
+ $file->isDir() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() );
+ }
+ rmdir( $dir );
+ }
+}