From b822df6915d451fd96f356b71bdd0082f4093791 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Tue, 13 Oct 2026 17:23:20 +0200 Subject: [PATCH 1/3] =?UTF-8?q?wawp-theme-updater=201.0.1=20:=20seconde=20?= =?UTF-8?q?tentative=20du=20jeton=20frais=20et=20cause=20r=C3=A9elle=20dan?= =?UTF-8?q?s=20le=20message=20d'erreur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updater::filter_pre_download redemande une fois l'offre fraîche après une pause d'une seconde (Config::RETRY_DELAY, injectable), sauf refus de licence (401) : jamais plus de deux requêtes /v1/check par téléchargement. - Client::check rend une clé error (HTTP , réponse invalide, réseau : tronqué à 200 caractères, clé de licence masquée). - wawp_theme_token_unavailable cite la cause de la dernière tentative. - Config::VERSION et config/min-theme-updater-version.txt passent à 1.0.1. --- config/min-theme-updater-version.txt | 2 +- docs/mise-en-place.md | 2 +- tests/Unit/ThemeClientTest.php | 29 ++++++++- tests/Unit/ThemeUpdaterTest.php | 80 +++++++++++++++++++++++- tests/Unit/ThemeValidatorTest.php | 3 +- wawp-theme-updater/src/class-client.php | 25 +++++++- wawp-theme-updater/src/class-config.php | 5 +- wawp-theme-updater/src/class-updater.php | 45 ++++++++++--- 8 files changed, 174 insertions(+), 17 deletions(-) diff --git a/config/min-theme-updater-version.txt b/config/min-theme-updater-version.txt index 3eefcb9..7dea76e 100644 --- a/config/min-theme-updater-version.txt +++ b/config/min-theme-updater-version.txt @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/docs/mise-en-place.md b/docs/mise-en-place.md index 78f6ffa..2c01bca 100644 --- a/docs/mise-en-place.md +++ b/docs/mise-en-place.md @@ -122,7 +122,7 @@ Le thème est un thème FSE autonome (pas de thème parent, pas d'en-tête `Temp ``` 3. Dans `style.css` : `Update URI: https://updates.wearewp.pro`, `Requires at least: 6.1` (ou plus), `Requires PHP`, `Text Domain: `, `Version` à la prochaine version mineure. `Theme URI` est facultatif, mais s'il est vide le lien « Voir les détails de la version » de WordPress ne mène nulle part d'utile : renseigner `Theme URI: https://www.wearewp.pro/` (recommandé). 4. Créer `.distignore` avec des motifs racine ancrés par `/` (`/.git`, `/.github`, `/node_modules`, `/src`…). -5. Contrôler localement : `php /scripts/check-headers.php --type=theme v 1.0.0` → `Contrôles réussis`. +5. Contrôler localement : `php /scripts/check-headers.php --type=theme v 1.0.1` → `Contrôles réussis`. 6. Lancer, dans Git Bash, depuis le dossier de release-tools : `bash bin/setup-repo.sh wearewp/ ` (`Type détecté : theme.`), puis commiter `.github/workflows/release.yml`. 7. Ajouter le slug à la liste `themes` de la licence du site (`bin/new-key.php --theme=` pour une nouvelle licence, ou édition du registre puis `bin/check-licences.php`). 8. Publier comme un plugin (section 7, sans `readme.txt`), puis, après sauvegarde du site, installer une fois le zip publié à la main (Apparence › Thèmes › Ajouter › Téléverser, remplacer le thème actif). Les versions suivantes passent par les mises à jour de WordPress. diff --git a/tests/Unit/ThemeClientTest.php b/tests/Unit/ThemeClientTest.php index 97354e6..10f35e6 100644 --- a/tests/Unit/ThemeClientTest.php +++ b/tests/Unit/ThemeClientTest.php @@ -39,10 +39,10 @@ public function testCheckSendsOnlyTheThemeAndValidatesTheAnswer(): void self::assertSame(0, $args['redirection']); self::assertTrue($args['sslverify']); self::assertSame( - ['site' => 'https://client.fr', 'themes' => ['wawp-demo-theme' => '1.3.0'], 'wp' => '6.8.1', 'php' => PHP_VERSION, 'client' => '1.0.0'], + ['site' => 'https://client.fr', 'themes' => ['wawp-demo-theme' => '1.3.0'], 'wp' => '6.8.1', 'php' => PHP_VERSION, 'client' => '1.0.1'], json_decode($args['body'], true) ); - self::assertStringStartsWith('wawp-theme-updater/1.0.0; ', $args['user-agent']); + self::assertStringStartsWith('wawp-theme-updater/1.0.1; ', $args['user-agent']); self::assertStringNotContainsString(self::KEY, $args['user-agent']); } @@ -62,4 +62,29 @@ public function testStatusIsReportedAndOffersAreNullOnFailure(): void self::assertNull($result['offers'], $label); } } + + public function testErrorDescribesTheCauseWithoutTheLicenceKey(): void + { + $cases = [ + 'succès' => [static fn (): array => Http::response(200, Http::theme_body([])), null], + 'réseau' => [static fn () => new WP_Error('http_request_failed', 'cURL error 6: Could not resolve host'), 'réseau : http_request_failed cURL error 6: Could not resolve host'], + 'licence refusée' => [static fn (): array => Http::response(401, '{"status":"unauthorized"}'), 'HTTP 401'], + 'serveur indisponible' => [static fn (): array => Http::response(503, '{"status":"unavailable"}'), 'HTTP 503'], + 'réponse invalide' => [static fn (): array => Http::response(200, 'pas du JSON'), 'réponse invalide'], + 'réponse de plugins' => [static fn (): array => Http::response(200, '{"plugins":{}}'), 'réponse invalide'], + 'clé dans le message réseau' => [static fn () => new WP_Error('http_request_failed', "Authorization: Bearer " . self::KEY . "\n"), 'réseau : http_request_failed Authorization: Bearer *** b '], + ]; + foreach ($cases as $label => [$http, $error]) { + WawpTestState::$http = $http; + $result = (new Client())->check(self::KEY, 'https://client.fr', ['wawp-demo-theme' => '1.3.0']); + self::assertSame($error, $result['error'], $label); + } + + WawpTestState::$http = static fn () => new WP_Error('http_request_failed', 'cURL error 28: é' . str_repeat('x', 300) . self::KEY); + $error = (string) (new Client())->check(self::KEY, 'https://client.fr', ['wawp-demo-theme' => '1.3.0'])['error']; + self::assertStringStartsWith('réseau : http_request_failed cURL error 28: éxxx', $error); + self::assertSame(200, mb_strlen($error) - mb_strlen('réseau : http_request_failed '), 'message tronqué à 200 caractères'); + self::assertTrue(mb_check_encoding($error, 'UTF-8')); + self::assertStringNotContainsString(self::KEY, $error); + } } diff --git a/tests/Unit/ThemeUpdaterTest.php b/tests/Unit/ThemeUpdaterTest.php index 60bc91b..64cb8f3 100644 --- a/tests/Unit/ThemeUpdaterTest.php +++ b/tests/Unit/ThemeUpdaterTest.php @@ -76,7 +76,85 @@ protected function tearDown(): void /** @param list|null $keys */ private function updater(?array $keys = null): Updater { - return new Updater(self::SLUG, self::DIR, new Client(), $keys ?? [$this->pair['public']]); + // Pause nulle avant la seconde tentative du jeton frais : les tests n'attendent pas. + return new Updater(self::SLUG, self::DIR, new Client(), $keys ?? [$this->pair['public']], 0); + } + + /** + * Réponses successives de /v1/check : null pour une erreur réseau, sinon [statut, corps] ; 'offre' pour l'offre signée. + * + * @param list $answers + */ + private function answers(array $answers): void + { + $signature = Keys::sign_file($this->zip, $this->pair['secret']); + WawpTestState::$http = static function () use (&$answers, $signature) { + $answer = array_shift($answers); + if (null === $answer) { + return new WP_Error('http_request_failed', 'cURL error 28: Operation timed out'); + } + if ('offre' === $answer) { + return Http::response(200, Http::theme_body([self::SLUG => Http::theme_offer(self::SLUG, '1.4.0', $signature, 'jeton-frais')])); + } + + return Http::response($answer[0], $answer[1]); + }; + } + + public function testFreshTokenIsRequestedASecondTimeAfterANetworkFailure(): void + { + $this->answers([null, 'offre']); + $path = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + + self::assertSame($this->zip . '.dl', $path, 'fichier vérifié après la seconde tentative'); + self::assertCount(2, WawpTestState::$requests); + self::assertSame('https://updates.wearewp.pro/v1/check', WawpTestState::$requests[1][1]); + $cache = WawpTestState::$transients['wawp_theme_updater_check']; + self::assertSame(43200, $cache['ttl'], 'la seconde tentative réussie écrit le cache de succès'); + self::assertFalse($cache['value']['denied']); + } + + public function testTwoFailuresReportTheCauseOfTheSecondAttempt(): void + { + $this->answers([null, [503, '{"status":"unavailable"}'], 'offre']); + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_theme_token_unavailable', $result->get_error_code()); + self::assertSame('Mise à jour du thème WeAre[WP] indisponible (HTTP 503) : licence refusée, réponse invalide ou serveur injoignable.', $result->get_error_message()); + self::assertCount(2, WawpTestState::$requests, 'jamais plus de deux requêtes /v1/check par téléchargement'); + self::assertSame(3600, WawpTestState::$transients['wawp_theme_updater_check']['ttl']); + self::assertStringNotContainsString(self::KEY, $result->get_error_message()); + } + + public function testNetworkFailureTwiceReportsTheNetworkCause(): void + { + $this->answers([[503, '{"status":"unavailable"}'], null]); + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + + self::assertStringContainsString('(réseau : http_request_failed cURL error 28: Operation timed out)', $result->get_error_message()); + self::assertStringNotContainsString(self::KEY, $result->get_error_message()); + } + + public function testRefusedLicenceIsNotRetried(): void + { + $this->answers([[401, '{"status":"unauthorized"}'], 'offre']); + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_theme_token_unavailable', $result->get_error_code()); + self::assertStringContainsString('(HTTP 401)', $result->get_error_message()); + self::assertCount(1, WawpTestState::$requests, 'refus de licence : pas de seconde tentative'); + self::assertTrue(WawpTestState::$transients['wawp_theme_updater_check']['value']['denied']); + } + + public function testInvalidAnswerIsReportedAsSuch(): void + { + $this->answers([[200, 'pas du JSON'], [200, '{"plugins":{}}']]); + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + + self::assertSame('Mise à jour du thème WeAre[WP] indisponible (réponse invalide) : licence refusée, réponse invalide ou serveur injoignable.', $result->get_error_message()); + self::assertCount(2, WawpTestState::$requests); } private function notice(Updater $updater, string $screen): string diff --git a/tests/Unit/ThemeValidatorTest.php b/tests/Unit/ThemeValidatorTest.php index db96732..f785ad1 100644 --- a/tests/Unit/ThemeValidatorTest.php +++ b/tests/Unit/ThemeValidatorTest.php @@ -26,7 +26,8 @@ private static function body(array $changes = []): string public function testConfigValues(): void { - self::assertSame('1.0.0', Config::VERSION); + self::assertSame('1.0.1', Config::VERSION); + self::assertSame(1, Config::RETRY_DELAY); self::assertSame('updates.wearewp.pro', Config::HOST); self::assertSame('https://updates.wearewp.pro', Config::ENDPOINT); self::assertSame('https://updates.wearewp.pro/v1/theme-update/', Config::PACKAGE_MARKER); diff --git a/wawp-theme-updater/src/class-client.php b/wawp-theme-updater/src/class-client.php index 8c0327a..9032228 100644 --- a/wawp-theme-updater/src/class-client.php +++ b/wawp-theme-updater/src/class-client.php @@ -16,12 +16,12 @@ final class Client { /** * Interroge /v1/check pour le thème inscrit. Le statut HTTP est rendu pour distinguer un refus de licence (401) - * d'une panne. + * d'une panne, et une courte cause d'échec (null en cas de succès), sans la clé de licence. * * @param string $key Clé de licence. * @param string $site Adresse du site. * @param array $installed Slug vers version installée. - * @return array{status: int, offers: array|null} + * @return array{status: int, offers: array|null, error: ?string} */ public function check( string $key, string $site, array $installed ): array { $response = wp_safe_remote_post( @@ -50,6 +50,7 @@ public function check( string $key, string $site, array $installed ): array { return array( 'status' => 0, 'offers' => null, + 'error' => 'réseau : ' . self::network_error( $response, $key ), ); } $status = (int) wp_remote_retrieve_response_code( $response ); @@ -57,12 +58,30 @@ public function check( string $key, string $site, array $installed ): array { return array( 'status' => $status, 'offers' => null, + 'error' => 'HTTP ' . $status, ); } + $offers = Theme_Validator::check_response( wp_remote_retrieve_body( $response ), array_map( 'strval', array_keys( $installed ) ) ); return array( 'status' => 200, - 'offers' => Theme_Validator::check_response( wp_remote_retrieve_body( $response ), array_map( 'strval', array_keys( $installed ) ) ), + 'offers' => $offers, + 'error' => null === $offers ? 'réponse invalide' : null, ); } + + /** + * Code et message d'une erreur de transport, en texte brut d'une ligne : clé de licence masquée, message tronqué + * à 200 caractères. La requête /v1/check ne porte aucun jeton de téléchargement. + * + * @param \WP_Error $error Erreur de transport. + * @param string $key Clé de licence. + */ + private static function network_error( \WP_Error $error, string $key ): string { + $message = str_replace( $key, '***', (string) $error->get_error_message() ); + $message = (string) preg_replace( '/[\x00-\x1F\x7F<>]/', ' ', $message ); + + // mb_substr() est toujours défini : WordPress en fournit une version de repli (wp-includes/compat.php). + return (string) $error->get_error_code() . ' ' . mb_substr( $message, 0, 200, 'UTF-8' ); + } } diff --git a/wawp-theme-updater/src/class-config.php b/wawp-theme-updater/src/class-config.php index 1083748..33ead33 100644 --- a/wawp-theme-updater/src/class-config.php +++ b/wawp-theme-updater/src/class-config.php @@ -15,7 +15,7 @@ final class Config { /** Version de la bibliothèque. */ - const VERSION = '1.0.0'; + const VERSION = '1.0.1'; /** Hôte de l'en-tête Update URI des thèmes WeAre[WP]. */ const HOST = 'updates.wearewp.pro'; @@ -50,6 +50,9 @@ final class Config { /** Délai réseau des appels à l'API. */ const TIMEOUT = 10; + /** Pause (en secondes) avant la seconde et dernière demande de jeton frais au téléchargement. */ + const RETRY_DELAY = 1; + /** Délai de téléchargement d'un paquet. */ const DOWNLOAD_TIMEOUT = 300; } diff --git a/wawp-theme-updater/src/class-updater.php b/wawp-theme-updater/src/class-updater.php index 316ac25..07f57b8 100644 --- a/wawp-theme-updater/src/class-updater.php +++ b/wawp-theme-updater/src/class-updater.php @@ -38,17 +38,33 @@ final class Updater { */ private $public_keys; + /** + * Pause en secondes avant la seconde demande de jeton frais. + * + * @var int + */ + private $retry_delay; + + /** + * Résultat de la dernière requête /v1/check faite par offers(), ou null si aucune requête n'a été faite. + * + * @var array{status: int, error: ?string}|null + */ + private $last_check = null; + /** * @param string $stylesheet Nom du dossier du thème actif. * @param string $dir Chemin du dossier du thème actif. * @param Client $client Client HTTP. * @param string[] $public_keys Clés publiques Ed25519 en base64. + * @param int $retry_delay Pause en secondes avant la seconde demande de jeton frais (0 dans les tests). */ - public function __construct( string $stylesheet, string $dir, Client $client, array $public_keys ) { + public function __construct( string $stylesheet, string $dir, Client $client, array $public_keys, int $retry_delay = Config::RETRY_DELAY ) { $this->stylesheet = $stylesheet; $this->dir = $dir; $this->client = $client; $this->public_keys = $public_keys; + $this->retry_delay = $retry_delay; } /** @@ -140,8 +156,18 @@ public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) return new \WP_Error( 'wawp_theme_unknown_package', __( 'Paquet de thème WeAre[WP] inconnu : mise à jour annulée.', 'wawp-theme-updater' ) ); } $offers = $this->offers( true ); + // Une seule seconde tentative après une courte pause (incident réseau passager), jamais après un refus de licence. + if ( ! isset( $offers[ $slug ] ) && null !== $this->last_check && 401 !== $this->last_check['status'] ) { + if ( $this->retry_delay > 0 ) { + sleep( $this->retry_delay ); + } + $offers = $this->offers( true ); + } if ( ! isset( $offers[ $slug ] ) ) { - return new \WP_Error( 'wawp_theme_token_unavailable', __( 'Mise à jour du thème WeAre[WP] indisponible : licence refusée ou serveur injoignable.', 'wawp-theme-updater' ) ); + $cause = null === $this->last_check ? __( 'configuration locale incomplète', 'wawp-theme-updater' ) : ( $this->last_check['error'] ?? __( 'aucune offre pour ce thème', 'wawp-theme-updater' ) ); + + /* translators: %s: cause de l'échec de la dernière demande au serveur (par exemple « HTTP 503 »). */ + return new \WP_Error( 'wawp_theme_token_unavailable', sprintf( __( 'Mise à jour du thème WeAre[WP] indisponible (%s) : licence refusée, réponse invalide ou serveur injoignable.', 'wawp-theme-updater' ), $cause ) ); } $offer = $offers[ $slug ]; $file = download_url( $offer['package'], Config::DOWNLOAD_TIMEOUT ); @@ -231,9 +257,10 @@ public function admin_notice(): void { * @return array */ private function offers( bool $fresh ): array { - $key = $this->key(); - $slug = $this->slug(); - $installed = $this->installed(); + $this->last_check = null; + $key = $this->key(); + $slug = $this->slug(); + $installed = $this->installed(); if ( null === $key || null === $slug || null === $installed ) { return array(); } @@ -250,8 +277,12 @@ private function offers( bool $fresh ): array { } } - $result = $this->client->check( $key, $site, array( $slug => $installed ) ); - $offers = $result['offers']; + $result = $this->client->check( $key, $site, array( $slug => $installed ) ); + $offers = $result['offers']; + $this->last_check = array( + 'status' => $result['status'], + 'error' => $result['error'], + ); set_site_transient( Config::CACHE_KEY, array( From c7a00bd7a279b5a311ef0af369043b3e1c82b6c7 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Tue, 13 Oct 2026 17:25:44 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Th=C3=A8mes=20:=20tag=20v1.2=20de=20release?= =?UTF-8?q?-tools=20pour=20wawp-theme-updater=201.0.1=20(tools-ref,=20mod?= =?UTF-8?q?=C3=A8le=20d'appel,=20documentation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/theme-release.yml | 2 +- README.md | 2 +- docs/mise-en-place.md | 10 +++++----- templates/theme-release.yml | 2 +- tests/Unit/WorkflowPolicyTest.php | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml index 63ed079..0b57fb5 100644 --- a/.github/workflows/theme-release.yml +++ b/.github/workflows/theme-release.yml @@ -6,7 +6,7 @@ on: tools-ref: description: Version de release-tools utilisée pour les scripts (doit correspondre à la version du workflow). type: string - default: v1.1 + default: v1.2 permissions: contents: read diff --git a/README.md b/README.md index 7d3b50d..09ea51f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Outils de publication des plugins et des thèmes WeAre[WP], clients de mise à j - `wawp-updater/` : bibliothèque embarquée dans chaque plugin (PHP 7.4+, WordPress 5.8+). Vérifie les mises à jour sur `updates.wearewp.pro` avec la clé `WAWP_LICENSE_KEY` et refuse tout paquet dont la signature Ed25519 est invalide. Figée en version 1.0.0. - `wawp-theme-updater/` : bibliothèque embarquée dans le thème FSE autonome d'un client (PHP 7.4+, WordPress 6.1+), mêmes garanties, chargée par `functions.php`. -- `.github/workflows/plugin-release.yml` (tag `v1`) et `.github/workflows/theme-release.yml` (tag `v1.1`) : workflows réutilisables, déclenchés par un tag `vX.Y.Z` dans le dépôt d'un plugin ou d'un thème. +- `.github/workflows/plugin-release.yml` (tag `v1`) et `.github/workflows/theme-release.yml` (tag `v1.2`) : workflows réutilisables, déclenchés par un tag `vX.Y.Z` dans le dépôt d'un plugin ou d'un thème. - `scripts/`, `bin/` : contrôles (`--type=theme` pour un thème), signature, envoi FTPS, génération des clés, préparation d'un dépôt. Ce dépôt ne contient aucun secret. Mise en place : `docs/mise-en-place.md`. diff --git a/docs/mise-en-place.md b/docs/mise-en-place.md index 2c01bca..e542ad5 100644 --- a/docs/mise-en-place.md +++ b/docs/mise-en-place.md @@ -98,14 +98,14 @@ Suivre la section 11 de la spec (`docs/superpowers/specs/2026-10-12-serveur-mise La version de rotation de `wawp-updater` incrémente `$wawp_updater_version` dans `load.php` et `Config::VERSION` dans `src/class-config.php`, toujours égales (par exemple `1.0.0` vers `1.1.0`) : c'est la copie la plus récente qui est chargée sur un site. Une fois cette version taguée, relever `config/min-updater-version.txt` sur la branche `main` de release-tools à cette même version : chaque publication lit aussi ce fichier sur `main` et retient la plus haute des deux valeurs, ce qui bloque la publication d'un plugin qui embarque encore l'ancien client, même si son workflow est épinglé sur un ancien tag. -## 9. Tag `v1.1` de release-tools (une seule fois) +## 9. Tag `v1.2` de release-tools (une seule fois) -Les thèmes utilisent `theme-release.yml@v1.1` ; les plugins restent sur `@v1`. Après fusion sur `main` de la branche qui ajoute `wawp-theme-updater/` et `theme-release.yml`, depuis un clone de release-tools, dans Git Bash (`gh` authentifié comme à l'étape 5) : +Les thèmes utilisent `theme-release.yml@v1.2` ; les plugins restent sur `@v1`. Le tag `v1.1` (wawp-theme-updater 1.0.0) n'est plus utilisé : `config/min-theme-updater-version.txt` exige 1.0.1. Après fusion sur `main` de la branche qui passe `wawp-theme-updater/` en 1.0.1, depuis un clone de release-tools, dans Git Bash (`gh` authentifié comme à l'étape 5) : ``` git checkout main && git pull -git tag v1.1 -git push origin v1.1 +git tag v1.2 +git push origin v1.2 ``` Le ruleset de l'étape 5 protège aussi ce tag (motif `refs/tags/v*`) : il ne pourra être ni déplacé ni supprimé. @@ -114,7 +114,7 @@ Le ruleset de l'étape 5 protège aussi ce tag (motif `refs/tags/v*`) : il ne po Le thème est un thème FSE autonome (pas de thème parent, pas d'en-tête `Template`), actif sur un seul site. Le dépôt appartient à l'organisation `wearewp`, est privé et porte le nom du slug du thème, qui est aussi le nom de son dossier et son `Text Domain`. -1. Copier `wawp-theme-updater/` (version taguée `v1.1`) à la racine du thème : `git -C archive v1.1 wawp-theme-updater | tar -x -C `. +1. Copier `wawp-theme-updater/` (version taguée `v1.2`) à la racine du thème : `git -C archive v1.2 wawp-theme-updater | tar -x -C `. 2. Dans `functions.php`, après le contrôle `ABSPATH` : ```php require __DIR__ . '/wawp-theme-updater/load.php'; diff --git a/templates/theme-release.yml b/templates/theme-release.yml index 1bdf1be..cdef9fe 100644 --- a/templates/theme-release.yml +++ b/templates/theme-release.yml @@ -10,5 +10,5 @@ jobs: release: permissions: contents: write - uses: wearewp/release-tools/.github/workflows/theme-release.yml@v1.1 + uses: wearewp/release-tools/.github/workflows/theme-release.yml@v1.2 secrets: inherit diff --git a/tests/Unit/WorkflowPolicyTest.php b/tests/Unit/WorkflowPolicyTest.php index bb99c4e..737ea7a 100644 --- a/tests/Unit/WorkflowPolicyTest.php +++ b/tests/Unit/WorkflowPolicyTest.php @@ -180,7 +180,7 @@ public function testThemeWorkflowSharesSigningAndReleaseJobsWithThePluginOne(): self::assertStringContainsString("permissions: contents: read ", $yaml); - self::assertStringContainsString(" default: v1.1 + self::assertStringContainsString(" default: v1.2 ", $yaml); self::assertSame(2, substr_count($yaml, 'overwrite: true')); self::assertStringNotContainsString('--clobber', $yaml); @@ -217,7 +217,7 @@ public function testThemeCallerTemplate(): void $yaml = self::file('templates/theme-release.yml'); self::assertStringContainsString("tags: ['v*.*.*']", $yaml); self::assertStringContainsString('permissions: {}', $yaml); - self::assertStringContainsString('uses: wearewp/release-tools/.github/workflows/theme-release.yml@v1.1', $yaml); + self::assertStringContainsString('uses: wearewp/release-tools/.github/workflows/theme-release.yml@v1.2', $yaml); self::assertStringContainsString('secrets: inherit', $yaml); self::assertStringContainsString('uses: wearewp/release-tools/.github/workflows/plugin-release.yml@v1', self::file('templates/release.yml'), 'les plugins restent sur v1'); } From f77704df5f2fbae69d1879e81cd57522352257b4 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Tue, 13 Oct 2026 17:29:27 +0200 Subject: [PATCH 3/3] =?UTF-8?q?wawp-theme-updater=201.0.1=20:=20code=20d'e?= =?UTF-8?q?rreur=20r=C3=A9seau=20assaini=20comme=20le=20message,=20causes?= =?UTF-8?q?=20traduisibles,=20test=20503=20puis=20r=C3=A9seau=20renomm?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Unit/ThemeClientTest.php | 1 + tests/Unit/ThemeUpdaterTest.php | 5 +++-- wawp-theme-updater/src/class-client.php | 23 ++++++++++++----------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/Unit/ThemeClientTest.php b/tests/Unit/ThemeClientTest.php index 10f35e6..24ec536 100644 --- a/tests/Unit/ThemeClientTest.php +++ b/tests/Unit/ThemeClientTest.php @@ -73,6 +73,7 @@ public function testErrorDescribesTheCauseWithoutTheLicenceKey(): void 'réponse invalide' => [static fn (): array => Http::response(200, 'pas du JSON'), 'réponse invalide'], 'réponse de plugins' => [static fn (): array => Http::response(200, '{"plugins":{}}'), 'réponse invalide'], 'clé dans le message réseau' => [static fn () => new WP_Error('http_request_failed', "Authorization: Bearer " . self::KEY . "\n"), 'réseau : http_request_failed Authorization: Bearer *** b '], + 'clé et balises dans le code réseau' => [static fn () => new WP_Error("\n" . self::KEY, 'timeout'), 'réseau : i *** timeout'], ]; foreach ($cases as $label => [$http, $error]) { WawpTestState::$http = $http; diff --git a/tests/Unit/ThemeUpdaterTest.php b/tests/Unit/ThemeUpdaterTest.php index 64cb8f3..2729d12 100644 --- a/tests/Unit/ThemeUpdaterTest.php +++ b/tests/Unit/ThemeUpdaterTest.php @@ -127,13 +127,14 @@ public function testTwoFailuresReportTheCauseOfTheSecondAttempt(): void self::assertStringNotContainsString(self::KEY, $result->get_error_message()); } - public function testNetworkFailureTwiceReportsTheNetworkCause(): void + public function testServerErrorThenNetworkFailureReportsTheNetworkCause(): void { - $this->answers([[503, '{"status":"unavailable"}'], null]); + $this->answers([[503, '{"status":"unavailable"}'], null, 'offre']); $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); self::assertStringContainsString('(réseau : http_request_failed cURL error 28: Operation timed out)', $result->get_error_message()); self::assertStringNotContainsString(self::KEY, $result->get_error_message()); + self::assertCount(2, WawpTestState::$requests, 'jamais plus de deux requêtes /v1/check par téléchargement'); } public function testRefusedLicenceIsNotRetried(): void diff --git a/wawp-theme-updater/src/class-client.php b/wawp-theme-updater/src/class-client.php index 9032228..3b7eec8 100644 --- a/wawp-theme-updater/src/class-client.php +++ b/wawp-theme-updater/src/class-client.php @@ -50,7 +50,8 @@ public function check( string $key, string $site, array $installed ): array { return array( 'status' => 0, 'offers' => null, - 'error' => 'réseau : ' . self::network_error( $response, $key ), + /* translators: %s: code et message de l'erreur de transport WordPress. */ + 'error' => sprintf( __( 'réseau : %s', 'wawp-theme-updater' ), self::plain( (string) $response->get_error_code(), $key ) . ' ' . self::plain( (string) $response->get_error_message(), $key ) ), ); } $status = (int) wp_remote_retrieve_response_code( $response ); @@ -58,7 +59,8 @@ public function check( string $key, string $site, array $installed ): array { return array( 'status' => $status, 'offers' => null, - 'error' => 'HTTP ' . $status, + /* translators: %d: statut HTTP de la réponse du serveur. */ + 'error' => sprintf( __( 'HTTP %d', 'wawp-theme-updater' ), $status ), ); } $offers = Theme_Validator::check_response( wp_remote_retrieve_body( $response ), array_map( 'strval', array_keys( $installed ) ) ); @@ -66,22 +68,21 @@ public function check( string $key, string $site, array $installed ): array { return array( 'status' => 200, 'offers' => $offers, - 'error' => null === $offers ? 'réponse invalide' : null, + 'error' => null === $offers ? __( 'réponse invalide', 'wawp-theme-updater' ) : null, ); } /** - * Code et message d'une erreur de transport, en texte brut d'une ligne : clé de licence masquée, message tronqué - * à 200 caractères. La requête /v1/check ne porte aucun jeton de téléchargement. + * Texte d'une erreur de transport (code ou message) réduit à du texte brut d'une ligne : clé de licence masquée, + * tronqué à 200 caractères. La requête /v1/check ne porte aucun jeton de téléchargement. * - * @param \WP_Error $error Erreur de transport. - * @param string $key Clé de licence. + * @param string $text Code ou message de l'erreur. + * @param string $key Clé de licence. */ - private static function network_error( \WP_Error $error, string $key ): string { - $message = str_replace( $key, '***', (string) $error->get_error_message() ); - $message = (string) preg_replace( '/[\x00-\x1F\x7F<>]/', ' ', $message ); + private static function plain( string $text, string $key ): string { + $text = (string) preg_replace( '/[\x00-\x1F\x7F<>]/', ' ', str_replace( $key, '***', $text ) ); // mb_substr() est toujours défini : WordPress en fournit une version de repli (wp-includes/compat.php). - return (string) $error->get_error_code() . ' ' . mb_substr( $message, 0, 200, 'UTF-8' ); + return mb_substr( $text, 0, 200, 'UTF-8' ); } }