Commit b9d5c8fce1d for woocommerce

commit b9d5c8fce1d016d0c134d6474d9e655903db89e3
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Tue Aug 25 16:39:38 2026 +0300

    Overwrite the combined translation file when publishing it fails (#67957)

    * Overwrite the combined translation file when publishing it fails

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Delete the translation temp file as a file, not a directory

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Remove the combined translation file when an in-place write truncates it

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Cover the SSH2 move that removes the destination before failing

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-wooairr-31-stale-combined-translations b/plugins/woocommerce/changelog/fix-wooairr-31-stale-combined-translations
new file mode 100644
index 00000000000..688a831266b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-31-stale-combined-translations
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Same-cycle regression from #67548, never present in a stable release; a Fix entry would describe a bug no 11.1.x upgrader experienced.
diff --git a/plugins/woocommerce/src/Internal/Admin/Translations.php b/plugins/woocommerce/src/Internal/Admin/Translations.php
index 6ee3cbdc7c1..d2c15e6c3b4 100644
--- a/plugins/woocommerce/src/Internal/Admin/Translations.php
+++ b/plugins/woocommerce/src/Internal/Admin/Translations.php
@@ -245,28 +245,47 @@ class Translations {
 		$cache_filename          = $this->get_combined_translation_filename( $plugin_domain, $locale );
 		$chunk_translations_json = wp_json_encode( $translations_from_chunks );

-		// Publish via a temp file so readers never observe a partial write.
+		// Publish via a temp file so readers do not observe a partial write; the fallback below
+		// gives that up rather than leave the previous pack's file in place.
 		$temp_path  = $language_dir . $cache_filename . '.' . wp_generate_password( 12, false ) . '.tmp';
 		$cache_path = $language_dir . $cache_filename;
+		$published  = false;

-		if ( ! $wp_filesystem->put_contents( $temp_path, $chunk_translations_json ) ) {
-			$wp_filesystem->delete( $temp_path );
-			return;
+		if ( $wp_filesystem->put_contents( $temp_path, $chunk_translations_json ) ) {
+			// Not WP_Filesystem_Direct::move(): it deletes the destination before renaming.
+			if ( 'direct' === get_filesystem_method() ) {
+				// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace; failure handled below.
+				$published = @rename( $temp_path, $cache_path );
+			} else {
+				// Remote filesystems: overwrite only when refreshing an existing file.
+				$published = $wp_filesystem->move( $temp_path, $cache_path, $wp_filesystem->exists( $cache_path ) );
+			}
 		}

-		// Not WP_Filesystem_Direct::move(): it deletes the destination before renaming.
-		if ( 'direct' === get_filesystem_method() ) {
-			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace; failure handled below.
-			if ( ! @rename( $temp_path, $cache_path ) ) {
-				$wp_filesystem->delete( $temp_path );
+		if ( ! $published ) {
+			/*
+			 * Reached when the temp file could not be written, and when publishing it failed: the
+			 * FTP filesystems ignore the $overwrite argument and return ftp_rename() as-is, so
+			 * servers that refuse to rename onto an existing path end up here. Without an in-place
+			 * overwrite the previous language pack's file would survive, and nothing would ever
+			 * replace it: the on-demand rebuild only runs when the file is missing, and never on a
+			 * remote filesystem.
+			 */
+			if ( ! $wp_filesystem->put_contents( $cache_path, $chunk_translations_json ) ) {
+				/*
+				 * An in-place write truncates the file before it can fail, so what survives may be
+				 * invalid JSON. A missing file recovers on its own - the rebuild triggers on it and
+				 * readers fall back to their own translation file - while a corrupt one is served
+				 * as-is forever.
+				 */
+				$leftover = $wp_filesystem->exists( $cache_path ) ? $wp_filesystem->get_contents( $cache_path ) : false;
+				if ( false !== $leftover && null === json_decode( $leftover, true ) ) {
+					$wp_filesystem->delete( $cache_path, false, 'f' );
+				}
 			}
-			return;
-		}

-		// Remote filesystems: overwrite only when refreshing an existing file.
-		$overwrite = $wp_filesystem->exists( $cache_path );
-		if ( ! $wp_filesystem->move( $temp_path, $cache_path, $overwrite ) ) {
-			$wp_filesystem->delete( $temp_path );
+			// Without the type, FTPext::delete() falls through to ftp_rmdir() when the file is absent.
+			$wp_filesystem->delete( $temp_path, false, 'f' );
 		}
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
index a07ad03476d..ce054d580ba 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
@@ -80,6 +80,24 @@ class TranslationsTest extends WC_Unit_Test_Case {
 		return 'de_DE';
 	}

+	/**
+	 * Forces get_filesystem_method() to report a remote filesystem.
+	 *
+	 * @return string Filesystem method.
+	 */
+	public function force_ftpext_filesystem() {
+		return 'ftpext';
+	}
+
+	/**
+	 * Forces get_filesystem_method() to report an SSH2 filesystem.
+	 *
+	 * @return string Filesystem method.
+	 */
+	public function force_ssh2_filesystem() {
+		return 'ssh2';
+	}
+
 	/**
 	 * Writes a chunk translation JSON file in the official language pack format.
 	 *
@@ -195,6 +213,236 @@ class TranslationsTest extends WC_Unit_Test_Case {
 		$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after replacing the combined file' );
 	}

+	/**
+	 * @testdox Should overwrite the combined file in place when a remote filesystem refuses to move onto it.
+	 */
+	public function test_replaces_existing_combined_file_when_remote_move_fails(): void {
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->create_chunk_json(
+			'dddddddddddddddddddddddddddddddd',
+			WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+			array( 'Show' => array( 'Anzeigen' ) )
+		);
+
+		if ( ! function_exists( 'get_filesystem_method' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+		\WP_Filesystem();
+
+		$previous_filesystem = $GLOBALS['wp_filesystem'];
+
+		// Mirrors WP_Filesystem_FTPext against a server that refuses to rename onto an existing path.
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+		$GLOBALS['wp_filesystem'] = new class( null ) extends \WP_Filesystem_Direct {
+			/**
+			 * Always reports the move as failed.
+			 *
+			 * @param string $source      Path to the source file.
+			 * @param string $destination Path to the destination file.
+			 * @param bool   $overwrite   Whether to overwrite the destination.
+			 * @return bool Always false.
+			 */
+			public function move( $source, $destination, $overwrite = false ) {
+				return false;
+			}
+		};
+		add_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+
+		try {
+			$build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+			$build->setAccessible( true );
+			$build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+		} finally {
+			remove_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+			$GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+		}
+
+		$data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$this->assertArrayNotHasKey( 'stale', $data, 'The previous language pack file should not survive a failed move' );
+		$this->assertSame( array( 'Anzeigen' ), $data['locale_data']['messages']['Show'], 'The refreshed translations should be written in place instead' );
+		$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after the fallback write' );
+	}
+
+	/**
+	 * @testdox Should overwrite the combined file in place when the temp file cannot be written.
+	 */
+	public function test_replaces_existing_combined_file_when_temp_write_fails(): void {
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->create_chunk_json(
+			'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
+			WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+			array( 'Show' => array( 'Anzeigen' ) )
+		);
+
+		if ( ! function_exists( 'get_filesystem_method' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+		\WP_Filesystem();
+
+		$previous_filesystem = $GLOBALS['wp_filesystem'];
+
+		// Mirrors a language directory that allows writing its existing files but not creating new ones.
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+		$GLOBALS['wp_filesystem'] = new class( null ) extends \WP_Filesystem_Direct {
+			/**
+			 * Rejects writes to the temp file, allowing every other write through.
+			 *
+			 * @param string    $file     Path to the file.
+			 * @param string    $contents Contents to write.
+			 * @param int|false $mode     Optional file permissions.
+			 * @return bool Whether the contents were written.
+			 */
+			public function put_contents( $file, $contents, $mode = false ) {
+				if ( str_ends_with( $file, '.tmp' ) ) {
+					return false;
+				}
+				return parent::put_contents( $file, $contents, $mode );
+			}
+		};
+
+		try {
+			$build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+			$build->setAccessible( true );
+			$build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+		} finally {
+			$GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+		}
+
+		$data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$this->assertArrayNotHasKey( 'stale', $data, 'The previous language pack file should not survive a failed temp write' );
+		$this->assertSame( array( 'Anzeigen' ), $data['locale_data']['messages']['Show'], 'The refreshed translations should be written in place instead' );
+		$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after the fallback write' );
+	}
+
+	/**
+	 * @testdox Should recreate the combined file when a remote move removes it before failing.
+	 */
+	public function test_recreates_combined_file_when_remote_move_destroys_it(): void {
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->create_chunk_json(
+			'ffffffffffffffffffffffffffffffff',
+			WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+			array( 'Show' => array( 'Anzeigen' ) )
+		);
+
+		if ( ! function_exists( 'get_filesystem_method' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+		\WP_Filesystem();
+
+		$previous_filesystem = $GLOBALS['wp_filesystem'];
+
+		// Mirrors WP_Filesystem_SSH2::move(), which removes the destination before renaming onto it.
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+		$GLOBALS['wp_filesystem'] = new class( null ) extends \WP_Filesystem_Direct {
+			/**
+			 * Removes the destination, then reports the move as failed.
+			 *
+			 * @param string $source      Path to the source file.
+			 * @param string $destination Path to the destination file.
+			 * @param bool   $overwrite   Whether to overwrite the destination.
+			 * @return bool Always false.
+			 */
+			public function move( $source, $destination, $overwrite = false ) {
+				$this->delete( $destination, false, 'f' );
+				return false;
+			}
+		};
+		add_filter( 'filesystem_method', array( $this, 'force_ssh2_filesystem' ), PHP_INT_MAX );
+
+		try {
+			$build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+			$build->setAccessible( true );
+			$build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+		} finally {
+			remove_filter( 'filesystem_method', array( $this, 'force_ssh2_filesystem' ), PHP_INT_MAX );
+			$GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+		}
+
+		$this->assertFileExists( $combined, 'A move that removes the destination should not leave the site without a combined file' );
+		$data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$this->assertSame( array( 'Anzeigen' ), $data['locale_data']['messages']['Show'], 'The recreated file should contain the rebuilt translations' );
+	}
+
+	/**
+	 * @testdox Should remove the combined file when the in-place write leaves it truncated.
+	 */
+	public function test_removes_combined_file_when_fallback_write_is_truncated(): void {
+		$combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+		file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->create_chunk_json(
+			'gggggggggggggggggggggggggggggggg',
+			WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+			array( 'Show' => array( 'Anzeigen' ) )
+		);
+
+		if ( ! function_exists( 'get_filesystem_method' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+		}
+		\WP_Filesystem();
+
+		$previous_filesystem = $GLOBALS['wp_filesystem'];
+
+		// Mirrors WP_Filesystem_Direct::put_contents() truncating the file before a short write fails.
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+		$stub                = new class( null ) extends \WP_Filesystem_Direct {
+			/**
+			 * Path the truncated write is simulated for.
+			 *
+			 * @var string
+			 */
+			public $truncate_path = '';
+
+			/**
+			 * Always reports the move as failed.
+			 *
+			 * @param string $source      Path to the source file.
+			 * @param string $destination Path to the destination file.
+			 * @param bool   $overwrite   Whether to overwrite the destination.
+			 * @return bool Always false.
+			 */
+			public function move( $source, $destination, $overwrite = false ) {
+				return false;
+			}
+
+			/**
+			 * Writes a partial payload and reports failure for the tracked path.
+			 *
+			 * @param string    $file     Path to the file.
+			 * @param string    $contents Contents to write.
+			 * @param int|false $mode     Optional file permissions.
+			 * @return bool Whether the contents were written.
+			 */
+			public function put_contents( $file, $contents, $mode = false ) {
+				if ( $file === $this->truncate_path ) {
+					parent::put_contents( $file, '{"locale_data', $mode );
+					return false;
+				}
+				return parent::put_contents( $file, $contents, $mode );
+			}
+		};
+		$stub->truncate_path = $combined;
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+		$GLOBALS['wp_filesystem'] = $stub;
+		// The direct branch renames outside the filesystem object, so route publishing through move().
+		add_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+
+		try {
+			$build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+			$build->setAccessible( true );
+			$build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+		} finally {
+			remove_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+			$GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+		}
+
+		$this->assertFileDoesNotExist( $combined, 'A truncated write should not be left behind for readers to parse' );
+		$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after a truncated write' );
+	}
+
 	/**
 	 * @testdox Should fall back to the original file when nothing can be rebuilt.
 	 */