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 );
+	}
+}