Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/theme-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion config/min-theme-updater-version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0
1.0.1
12 changes: 6 additions & 6 deletions docs/mise-en-place.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é.
Expand All @@ -114,15 +114,15 @@ 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 <release-tools> archive v1.1 wawp-theme-updater | tar -x -C <thème>`.
1. Copier `wawp-theme-updater/` (version taguée `v1.2`) à la racine du thème : `git -C <release-tools> archive v1.2 wawp-theme-updater | tar -x -C <thème>`.
2. Dans `functions.php`, après le contrôle `ABSPATH` :
```php
require __DIR__ . '/wawp-theme-updater/load.php';
\WAWP\ThemeUpdater\register( __DIR__ );
```
3. Dans `style.css` : `Update URI: https://updates.wearewp.pro`, `Requires at least: 6.1` (ou plus), `Requires PHP`, `Text Domain: <slug>`, `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 <release-tools>/scripts/check-headers.php --type=theme <thème> <slug> v<version> 1.0.0` → `Contrôles réussis`.
5. Contrôler localement : `php <release-tools>/scripts/check-headers.php --type=theme <thème> <slug> v<version> 1.0.1` → `Contrôles réussis`.
6. Lancer, dans Git Bash, depuis le dossier de release-tools : `bash bin/setup-repo.sh wearewp/<slug> <chemin-local-du-thème>` (`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=<slug>` 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.
Expand Down
2 changes: 1 addition & 1 deletion templates/theme-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 28 additions & 2 deletions tests/Unit/ThemeClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}

Expand All @@ -62,4 +62,30 @@ 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<b>"), 'réseau : http_request_failed Authorization: Bearer *** b '],
'clé et balises dans le code réseau' => [static fn () => new WP_Error("<i>\n" . self::KEY, 'timeout'), 'réseau : i *** timeout'],
];
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);
}
}
81 changes: 80 additions & 1 deletion tests/Unit/ThemeUpdaterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,86 @@ protected function tearDown(): void
/** @param list<string>|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<array{int, string}|string|null> $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 testServerErrorThenNetworkFailureReportsTheNetworkCause(): void
{
$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
{
$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
Expand Down
3 changes: 2 additions & 1 deletion tests/Unit/ThemeValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/WorkflowPolicyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
}
Expand Down
26 changes: 23 additions & 3 deletions wawp-theme-updater/src/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $installed Slug vers version installée.
* @return array{status: int, offers: array<string, array{version: string, requires: ?string, requires_php: ?string, url: string, package: string, signature: string}>|null}
* @return array{status: int, offers: array<string, array{version: string, requires: ?string, requires_php: ?string, url: string, package: string, signature: string}>|null, error: ?string}
*/
public function check( string $key, string $site, array $installed ): array {
$response = wp_safe_remote_post(
Expand Down Expand Up @@ -50,19 +50,39 @@ public function check( string $key, string $site, array $installed ): array {
return array(
'status' => 0,
'offers' => null,
/* 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 );
if ( 200 !== $status ) {
return array(
'status' => $status,
'offers' => null,
/* 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 ) ) );

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', 'wawp-theme-updater' ) : null,
);
}

/**
* 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 string $text Code ou message de l'erreur.
* @param string $key Clé de licence.
*/
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 mb_substr( $text, 0, 200, 'UTF-8' );
}
}
5 changes: 4 additions & 1 deletion wawp-theme-updater/src/class-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Loading
Loading