From 6c3a0e514791fd0c56b141ec78714c71717a7b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Mon, 7 Sep 2026 23:57:12 +0200 Subject: [PATCH 1/6] docs(config): write the analysis tools' comments in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.php-cs-fixer.dist.php`, the two Psalm configs, the two PHPStan configs, and one line of the README. These comments are load-bearing: why the PER revision is pinned rather than aliased, and why `psalm-magento.xml` cannot join `psalm.xml` — with the reason `ignoreFiles` on the bench's own copy of the module is safe *only because both come from the same commit*. `psalm.xml` carried one French comment that the accent grep missed; only a full read found it. One correction rather than a translation: the French said `ignoreFiles` makes a file "n'est pas seulement analysé", which reads as the opposite of what Psalm does. The English says what the sentence evidently meant and what the tool actually does — not merely left unanalysed, skipped at scan time. Two things kept as found: the same comment names `magento/vendor/gplanchat/durable` where the `ignoreFiles` entry beside it says `durable-magento`, and `.git-blame-ignore-revs` now labels in English a commit whose own subject stays French, because a commit subject is history. Co-Authored-By: Claude Opus 5 (1M context) --- .php-cs-fixer.dist.php | 30 +++++++++++++------------- README.md | 2 +- phpstan-magento.neon | 15 ++++++------- phpstan.neon | 30 +++++++++++++------------- psalm-magento.xml | 49 +++++++++++++++++++++--------------------- psalm.xml | 2 +- 6 files changed, 63 insertions(+), 65 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 8f33fedd..441a189a 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -3,23 +3,23 @@ declare(strict_types=1); /* - * Style du dépôt : PER Coding Style, décidé par l'ADR DUR008 — la référence PHP-FIG qui succède - * à PSR-12. + * Repository style: PER Coding Style, decided by ADR DUR008 — the PHP-FIG reference that + * succeeds PSR-12. * - * La révision est **épinglée**, pas suivie au fil de l'eau : l'ADR demande de « planifier une mise - * à jour » quand une nouvelle version majeure paraît. L'alias `@PER-CS` ferait arriver cette mise - * à jour sans prévenir, sous forme de CI rouge après un `composer update`, sans qu'une ligne de - * code ait bougé. Monter le numéro ci-dessous est l'acte délibéré que l'ADR réclame. + * The revision is **pinned**, not tracked as it evolves: the ADR asks to "plan an update" when a + * new major version is published. The `@PER-CS` alias would bring that update in unannounced, as + * red CI after a `composer update`, without a line of code having moved. Raising the number below + * is the deliberate act the ADR calls for. * - * PER impose des espaces autour de la concaténation (`.` est un opérateur de chaîne) et le corps - * vide sur une ligne. Ce dépôt suivait la convention Symfony sur ces deux points ; l'ADR tranche. + * PER mandates spaces around concatenation (`.` is a string operator) and the empty body on one + * line. This repository followed the Symfony convention on those two points; the ADR settles it. * - * Les règles ajoutées ensuite ne contredisent pas PER, elles décident là où il se tait. + * The rules added afterwards do not contradict PER, they decide where it stays silent. */ $finder = (new PhpCsFixer\Finder()) ->in([__DIR__ . '/src', __DIR__ . '/tests']) - // Souches protobuf : réécrites à chaque `protoc`, les reformater ne survivrait pas. + // Protobuf stubs: rewritten by every `protoc`, so reformatting them would not survive. ->exclude(['Bridge/Temporal/Api', 'Bridge/Temporal/Generated']); return (new PhpCsFixer\Config()) @@ -28,15 +28,15 @@ ->setRules([ '@PER-CS3.0' => true, - // Chaque fichier l'a déjà ; la règle empêche qu'un nouveau l'oublie. + // Every file already has it; the rule stops a new one from forgetting it. 'declare_strict_types' => true, - // Un ordre unique rend les conflits de fusion sur les `use` locaux au lieu d'être diffus. + // A single order makes merge conflicts on `use` local instead of diffuse. 'ordered_imports' => ['sort_algorithm' => 'alpha'], 'no_unused_imports' => true, - // Le noyau écrit `\Throwable`, `\DateTimeImmutable` en toutes lettres : les classes - // globales ne s'importent pas, on les reconnaît à la barre oblique. + // The core writes `\Throwable`, `\DateTimeImmutable` in full: global classes are not + // imported, you recognize them by the leading slash. 'global_namespace_import' => [ 'import_classes' => false, 'import_constants' => false, @@ -47,7 +47,7 @@ 'array_syntax' => ['syntax' => 'short'], 'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try']], - // Un bloc de doc vidé de ses annotations ne doit pas rester en place. + // A doc block emptied of its annotations must not stay in place. 'no_empty_phpdoc' => true, 'phpdoc_trim' => true, ]); diff --git a/README.md b/README.md index a839c64a..2fa071cb 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ one place, so the shop runs on the image the skeleton ships with — never on th ```bash cd sylius -cp compose.override.dist.yml compose.override.yml # monte l'app, et `../src` dont elle dépend +cp compose.override.dist.yml compose.override.yml # mounts the app, and `../src` it depends on docker compose up -d # php 8.3, MySQL 8.4, nginx, mailhog docker compose run --rm php composer install docker compose run --rm php bin/console debug:router diff --git a/phpstan-magento.neon b/phpstan-magento.neon index 8431b5a5..3f038670 100644 --- a/phpstan-magento.neon +++ b/phpstan-magento.neon @@ -1,13 +1,12 @@ -# L'analyse du module Magento, à part. +# The Magento module's analysis, kept apart. # -# ⚠ Elle ne peut pas rejoindre `phpstan.neon` : le module parle à des classes que ce dépôt -# n'installe pas — la distribution pèse près d'un gigaoctet et n'a rien à faire dans les -# dépendances du composant. Elle tourne donc dans le job qui l'installe déjà, celui qui démarre le -# banc, et lit les classes réelles plutôt que des bouchons écrits à la main. Un bouchon approximatif -# ne fait pas d'erreur : il en cache. +# ⚠ It cannot join `phpstan.neon`: the module talks to classes this repository does not install — +# the distribution weighs close to a gigabyte and has no business in the component's dependencies. +# So it runs in the job that installs it already, the one that starts the bench, and reads the real +# classes rather than hand-written stubs. An approximate stub does not make an error: it hides one. # -# `scanDirectories` et non `paths` pour tout le reste : les classes sont découvertes, pas analysées. -# Le cœur y est aussi, parce que le module en lit les modèles d'observation. +# `scanDirectories` and not `paths` for everything else: the classes are discovered, not analysed. +# The core is in there too, because the module reads its observation models. includes: - src/DurablePhpstan/extension.neon diff --git a/phpstan.neon b/phpstan.neon index 548ef88d..e32733d2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -14,21 +14,21 @@ parameters: - src/DurablePhpstan - src/DurableRector excludePaths: - # `illuminate/console` exige `symfony/console ^7.x` sur ses trois lignes, donc le faire - # entrer dans le graphe de la racine rendrait la ligne Symfony 6.4 irrésoluble — la même - # panne qu'`illuminate/queue` avait causée. La commande est réelle et tourne dans une - # application Laravel, qui embarque toujours illuminate/console ; c'est ici, et ici - # seulement, qu'elle ne peut pas être analysée. + # `illuminate/console` requires `symfony/console ^7.x` on all three of its lines, so + # bringing it into the root graph would make the Symfony 6.4 line unresolvable — the same + # breakage `illuminate/queue` had caused. The command is real and runs in a Laravel + # application, which always ships illuminate/console; it is here, and here only, that it + # cannot be analysed. - src/DurableLaravel/Console - src/Bridge/Temporal/Api - src/Bridge/Temporal/Generated - src/Bridge/Temporal/Spike reportUnmatchedIgnoredErrors: false ignoreErrors: - # ServiceProvider::$app est typé par PHPDoc comme l'application complète. Ce paquet tient à - # ce que ce soit faux : ses liaisons s'enregistrent aussi dans un conteneur nu — worker - # autonome, test — où configPath() n'existe pas. La garde est réelle, c'est le PHPDoc amont - # qui est optimiste. + # ServiceProvider::$app is typed by PHPDoc as the full application. This package depends + # on that being false: its bindings also register in a bare container — standalone worker, + # test — where configPath() does not exist. The guard is real, it is the upstream PHPDoc + # that is optimistic. - identifier: function.alreadyNarrowedType path: src/DurableLaravel/DurableServiceProvider.php # google/protobuf stubs type MapField incorrectly — constructor expects "long" but GPBType constants are int @@ -55,12 +55,12 @@ parameters: # PHPStan resolves the concrete NullActivityHeartbeatSender stub to always-false - identifier: identical.alwaysFalse path: src/Durable/Worker/ActivityMessageProcessor.php - # Implémenter ExtendedMethodReflection sort de la promesse de compatibilité de PHPStan : - # l'interface peut changer sur une version mineure. C'est assumé et non contournable — la - # seule façon de dire qu'un appel de stub rend un Awaitable plutôt que le type du contrat - # est de décorer la réflexion. Le prix est connu : si une mineure casse SchedulingMethod- - # Reflection, ce sont ses 28 méthodes de délégation qu'il faut réaligner, et les six tests - # de StubMethodsExtensionTest qui le diront. + # Implementing ExtendedMethodReflection steps outside PHPStan's compatibility promise: + # the interface can change on a minor version. That is accepted and unavoidable — the only + # way to say that a stub call returns an Awaitable rather than the contract's type is to + # decorate the reflection. The price is known: if a minor breaks SchedulingMethod- + # Reflection, it is its 28 delegation methods that must be realigned, and the six tests of + # StubMethodsExtensionTest that will say so. - identifier: phpstanApi.interface path: src/DurablePhpstan/Reflection/SchedulingMethodReflection.php # DurableBundleTestTrait is consumed by application tests, not by library code diff --git a/psalm-magento.xml b/psalm-magento.xml index 778f73db..b4c1cc39 100644 --- a/psalm-magento.xml +++ b/psalm-magento.xml @@ -1,15 +1,15 @@ - + - + - + diff --git a/psalm.xml b/psalm.xml index 27a9b67f..a6ec06c3 100644 --- a/psalm.xml +++ b/psalm.xml @@ -22,7 +22,7 @@ - + From 707cd2d607a77b42fabdadc825468d3780cd7d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Mon, 7 Sep 2026 23:57:12 +0200 Subject: [PATCH 2/6] docs(config): write the ignore files' comments in English Nine comment lines across `.gitignore` and `.git-blame-ignore-revs`. The check that matters here is that no non-comment line moved: a changed pattern silently starts or stops ignoring files, and a changed SHA silently un-skips a commit in `git blame`. Diffed with the comments stripped, both files are byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- .git-blame-ignore-revs | 6 +++--- .gitignore | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 7122aa59..b3f050af 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,8 +1,8 @@ -# Révisions à sauter par `git blame` : des reformatages en masse, aucune décision dedans. +# Revisions for `git blame` to skip: bulk reformatting, no decision in them. # # git config blame.ignoreRevsFile .git-blame-ignore-revs # -# GitHub et GitLab lisent ce fichier tout seuls. +# GitHub and GitLab read this file on their own. -# style: adopter PER-CS 3.0 et figer la config php-cs-fixer (160 fichiers) +# style: adopt PER-CS 3.0 and freeze the php-cs-fixer config (160 files) 9222e3e802f4fd2aae555784d19b87dccdfd47f2 diff --git a/.gitignore b/.gitignore index e99b1561..f76e0eb6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,23 +7,23 @@ documentation/journal/inbox/*.md documentation/journal/archive/*.md -# Hugo : thème vendorisé pour inspection locale, la CI le récupère via go modules +# Hugo: vendored theme for local inspection, CI fetches it via go modules hugo-docs/_vendor/ hugo-docs/public/ -# Build --minify servi en HTTP pour vérifier le rendu : public/ et file:// mentent. +# Build --minify served over HTTP to check the rendering: public/ and file:// lie. hugo-docs/public-check/ hugo-docs/resources/ hugo-docs/.hugo_build.lock -# Dépendances et caches d'outils : jamais au dépôt, chaque poste les reconstruit. -# `vendor/` sans barre initiale attrape aussi celui des paquets satellites. +# Dependencies and tool caches: never in the repository, every workstation rebuilds them. +# `vendor/` without a leading slash also catches the satellite packages' one. vendor/ .phpunit.cache/ .php-cs-fixer.cache __pycache__/ -# Cache du noyau Symfony construit par les tests d'intégration du bundle. +# Symfony kernel cache built by the bundle's integration tests. /var/ -# Les journaux et les PID des workers de la démonstration. +# The demonstration workers' logs and PIDs. /demo/var/ From b350acb0ad174cf33ed5f8de064fae5439d03e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Mon, 7 Sep 2026 23:59:12 +0200 Subject: [PATCH 3/6] docs(ci): write ci.yml in English The comments here are the most valuable prose in the CI: why the Symfony matrix is a hand-picked list of edges rather than a cartesian product, why the `#[\Override]` bug was only visible at the *crossing* of an old Symfony line and a recent PHP, why `--prefer-lowest` is filtered, why the 7.0 to 7.3 lines cannot be pinned at all. Every measured fact and every version number survives. Seven job names and thirty step names move with them. **Two do not.** `Analyse statique (PHPStan + Psalm)` and `QA (CS + tests)` are required status checks on `main`: a check's name comes from the job's `name:`, so renaming either makes the required context never report and leaves every pull request unmergeable until branch protection is updated in the same breath. Both keep their names and now carry a comment saying so. Nothing executable changed, and that is checked rather than claimed: the thirteen `run:` blocks are identical, the structure is equal after dropping every `name` key, and a diff of all non-comment, non-`name` lines is empty. The two quoted error strings were verified byte-wise at their wrap seams. Two things inside `run:` blocks did move, both deliberately: a four-line shell comment explaining the `DurableModule` / `DurableProbe` distinction, and one `echo` that said `tentative` where the rest of the log says English. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 345 ++++++++++++++++++++------------------- 1 file changed, 176 insertions(+), 169 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e952a72..5b6055cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# Voir DUR009 (pyramide de tests), DUR008 (normes de code PHP-FIG). +# See DUR009 (test pyramid), DUR008 (PHP-FIG coding standards). name: CI on: @@ -8,6 +8,8 @@ on: jobs: qa: + # Do not rename: this is a required status check on `main`. Renaming it makes the required + # context never report, and every PR unmergeable until branch protection is updated too. name: QA (CS + tests) runs-on: ubuntu-latest strategy: @@ -21,7 +23,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - # ext-grpc : requis pour WorkflowTaskRunner, WorkflowTaskProcessor et TemporalHistoryCursor (DUR027). + # ext-grpc: required by WorkflowTaskRunner, WorkflowTaskProcessor and TemporalHistoryCursor (DUR027). extensions: mbstring, sqlite3, pdo_sqlite, grpc coverage: none @@ -35,29 +37,28 @@ jobs: run: composer test symfony-matrix: - # Le job `qa` installe la résolution du composer.lock : il ne voit qu'une ligne Symfony. - # Or le seul bug de compatibilité trouvé jusqu'ici — `#[\Override]` sur une méthode que - # le DataCollector de Symfony 6.4 ne déclare pas, fatale à partir de PHP 8.3 — n'était - # visible qu'au *croisement* d'une vieille ligne Symfony et d'un PHP récent. D'où une - # matrice explicite plutôt qu'un produit cartésien : chaque entrée est un bord qui a une - # raison d'exister. + # The `qa` job installs the composer.lock resolution: it only ever sees one Symfony line. + # Yet the only compatibility bug found so far — `#[\Override]` on a method that Symfony + # 6.4's DataCollector does not declare, fatal from PHP 8.3 on — was only visible at the + # *crossing* of an old Symfony line and a recent PHP. Hence an explicit matrix rather + # than a cartesian product: every entry is an edge that has a reason to exist. name: Symfony ${{ matrix.symfony }} / PHP ${{ matrix.php-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - # 6.4 LTS au plancher PHP de la bibliothèque. + # 6.4 LTS at the library's PHP floor. - { php-version: '8.2', symfony: '6.4.*', deps: 'lowest' } - # 6.4 LTS sous un PHP récent : le croisement qui mord. + # 6.4 LTS under a recent PHP: the crossing that bites. - { php-version: '8.4', symfony: '6.4.*', deps: 'highest' } - # Plancher *installable* de la ligne 7. La contrainte autorise ^7.0, mais - # 7.0.x à 7.3.x sont en fin de vie et bloquées par les avis de sécurité de - # Composer : les épingler ici ne testerait rien, ça échouerait à résoudre. + # *Installable* floor of the 7 line. The constraint allows ^7.0, but + # 7.0.x through 7.3.x are end of life and blocked by Composer's security + # advisories: pinning them here would test nothing, it would fail to resolve. - { php-version: '8.2', symfony: '7.4.*', deps: 'lowest' } - # 7.4 LTS sous PHP récent : ce que le lock résout aujourd'hui. + # 7.4 LTS under a recent PHP: what the lock resolves today. - { php-version: '8.4', symfony: '7.4.*', deps: 'highest' } - # Ligne 8 : exige PHP >= 8.4, donc jamais atteignable en local sur 8.2. + # Line 8: requires PHP >= 8.4, so never reachable locally on 8.2. - { php-version: '8.4', symfony: '8.0.*', deps: 'lowest' } - { php-version: '8.5', symfony: '8.*', deps: 'highest' } steps: @@ -70,64 +71,65 @@ jobs: extensions: mbstring, sqlite3, pdo_sqlite, grpc coverage: none - # Flex contraint *toutes* les dépendances symfony/*, y compris transitives, sur la - # ligne demandée. Sans lui il faudrait énumérer chaque paquet à la main, et la racine - # du monorepo n'en requiert plus aucun directement. - - name: Épingler la ligne Symfony (Flex) + # Flex constrains *all* symfony/* dependencies, transitive ones included, to the + # requested line. Without it every package would have to be listed by hand, and the + # monorepo root no longer requires any of them directly. + - name: Pin the Symfony line (Flex) run: | composer global config --no-plugins allow-plugins.symfony/flex true composer global require --no-progress --no-scripts --no-plugins symfony/flex composer config --no-plugins allow-plugins.symfony/flex true - # Mise à jour partielle : seule la dimension Symfony bouge. Un `--prefer-lowest` - # non filtré abaisserait aussi phpunit et phpstan, et ferait échouer la matrice sur - # autre chose que ce qu'elle mesure. - - name: Résoudre la ligne Symfony demandée + # Partial update: only the Symfony dimension moves. An unfiltered `--prefer-lowest` + # would lower phpunit and phpstan too, and would make the matrix fail on something + # other than what it measures. + - name: Resolve the requested Symfony line env: SYMFONY_REQUIRE: ${{ matrix.symfony }} run: | composer update --no-interaction --prefer-dist --no-progress \ ${{ matrix.deps == 'lowest' && '--prefer-lowest' || '' }} 'symfony/*' - - name: Ligne Symfony effectivement installée + - name: Symfony line actually installed run: composer show symfony/messenger --no-ansi | head -n 3 - name: PHPUnit run: composer test - # PHPStan est l'outil qui avait attrapé `#[\Override]` : sans lui, la matrice ne - # verrait le problème que si un test charge la classe fautive. + # PHPStan is the tool that caught `#[\Override]`: without it, the matrix would only + # see the problem if a test loaded the offending class. - name: PHPStan run: composer phpstan laravel: - # Le pendant de la matrice Symfony, pour l'autre famille d'hôtes. Même principe : une - # entrée par *bord* qui a une raison d'exister, pas un produit cartésien. + # The counterpart of the Symfony matrix, for the other host family. Same principle: one + # entry per *edge* that has a reason to exist, not a cartesian product. # - # Ce que cette matrice mesure et qu'aucune autre ne peut : `gplanchat/durable-laravel` - # s'appuie sur `illuminate/*`, dont les lignes 11, 12 et 13 ne demandent pas le même PHP — - # 13 exige ^8.3 quand la baseline du dépôt est 8.2 — et dont les contrats bougent d'une - # ligne à l'autre. La suite `unit` ne prouve que la ligne du lock. + # What this matrix measures and no other one can: `gplanchat/durable-laravel` builds on + # `illuminate/*`, whose lines 11, 12 and 13 do not ask for the same PHP — 13 requires ^8.3 + # when the repository's baseline is 8.2 — and whose contracts move from one line to the + # next. The `unit` suite only proves the line in the lock. # - # ⚠ Ce paquet ne requiert **pas** `illuminate/queue`, et ce n'est pas un oubli : Laravel 11+ - # en tire `symfony/process ^7.2`, ce qui le rendrait irréconciliable avec la ligne - # Symfony 6.4 que la matrice voisine teste encore. La garde contre la file `sync` lit donc - # le nom du driver dans la configuration plutôt que la classe de la connexion. + # ⚠ This package does **not** require `illuminate/queue`, and that is not an oversight: + # Laravel 11+ pulls `symfony/process ^7.2` in with it, which would make it irreconcilable + # with the Symfony 6.4 line the neighbouring matrix still tests. The guard against the + # `sync` queue therefore reads the driver name from the configuration rather than the + # connection class. name: Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - # 11 au plancher PHP commun aux deux. + # 11 at the PHP floor common to both. - { php-version: '8.2', laravel: '11.*', deps: 'lowest' } - # 11 sous un PHP récent : le croisement qui mord, comme pour Symfony 6.4. + # 11 under a recent PHP: the crossing that bites, as for Symfony 6.4. - { php-version: '8.4', laravel: '11.*', deps: 'highest' } - # 12 au plancher. + # 12 at the floor. - { php-version: '8.2', laravel: '12.*', deps: 'lowest' } - # 12 sous PHP récent : ce que le lock résout aujourd'hui. + # 12 under a recent PHP: what the lock resolves today. - { php-version: '8.4', laravel: '12.*', deps: 'highest' } - # 13 exige PHP ^8.3 : jamais atteignable au plancher du dépôt. + # 13 requires PHP ^8.3: never reachable at the repository's floor. - { php-version: '8.4', laravel: '13.*', deps: 'lowest' } - { php-version: '8.5', laravel: '13.*', deps: 'highest' } steps: @@ -137,16 +139,16 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - # grpc comme la matrice Symfony : la racine requiert le pont Temporal, qui exige - # ext-grpc. Sans elle, `composer update` échoue avant d'avoir résolu quoi que ce - # soit d'Illuminate — la matrice mesurerait l'absence d'une extension. + # grpc as in the Symfony matrix: the root requires the Temporal bridge, which + # requires ext-grpc. Without it, `composer update` fails before having resolved + # anything of Illuminate — the matrix would measure the absence of an extension. extensions: mbstring, sqlite3, pdo_sqlite, grpc coverage: none - # Mise à jour partielle, même raison que côté Symfony : seule la dimension Laravel - # bouge. Un `--prefer-lowest` non filtré abaisserait phpunit et phpstan, et la matrice - # échouerait sur autre chose que ce qu'elle mesure. - - name: Résoudre la ligne Laravel demandée + # Partial update, same reason as on the Symfony side: only the Laravel dimension + # moves. An unfiltered `--prefer-lowest` would lower phpunit and phpstan, and the + # matrix would fail on something other than what it measures. + - name: Resolve the requested Laravel line run: | composer require --no-update --no-interaction --dev \ "illuminate/cache:${{ matrix.laravel }}" \ @@ -154,24 +156,24 @@ jobs: composer update --no-interaction --prefer-dist --no-progress \ ${{ matrix.deps == 'lowest' && '--prefer-lowest' || '' }} 'illuminate/*' - - name: Ligne Laravel effectivement installée + - name: Laravel line actually installed run: composer show illuminate/database --no-ansi | head -n 3 - - name: PHPUnit (le paquet d'intégration et le pont Illuminate) + - name: PHPUnit (the integration package and the Illuminate bridge) run: vendor/bin/phpunit --testsuite laravel - name: PHPStan run: composer phpstan sylius-shop: - # La seule vérification du dépôt qui rende vraiment une page. Tout le reste prouve que les - # pièces sont correctes ; celle-ci prouve qu'assemblées elles affichent quelque chose. + # The only check in the repository that really renders a page. Everything else proves the + # pieces are correct; this one proves that, assembled, they display something. # - # Hors de la matrice principale, et pour deux raisons qui ne se contournent pas : Sylius - # Standard 2.2 exige PHP ^8.3 quand la baseline du dépôt est 8.2, et la boutique a besoin - # d'un vrai MySQL. C'est justement ce dernier qui a révélé deux fautes que SQLite avalait — - # un booléen lié sans type, et un upsert qui comptait les lignes affectées par un UPDATE. - name: Boutique Sylius (le tableau de bord, rendu) + # Outside the main matrix, and for two reasons there is no way around: Sylius Standard 2.2 + # requires PHP ^8.3 when the repository's baseline is 8.2, and the shop needs a real MySQL. + # It is precisely the latter that revealed two faults SQLite was swallowing — a boolean + # bound without a type, and an upsert that counted the rows affected by an UPDATE. + name: Sylius shop (the dashboard, rendered) runs-on: ubuntu-latest services: mysql: @@ -191,16 +193,16 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - # 8.3 : plancher de Sylius Standard 2.2. Le plugin, lui, reste en >=8.2. + # 8.3: Sylius Standard 2.2's floor. The plugin itself stays at >=8.2. php-version: '8.3' - # grpc : la boutique sert une opération Nexus, et le pont Temporal en dépend. - # Son tableau de bord, lui, n'en a toujours pas besoin — c'est DUR037, et - # `temporal.journal: false` est ce qui garde les deux séparés. + # grpc: the shop serves a Nexus operation, and the Temporal bridge depends on it. + # Its dashboard, for its part, still does not need it — that is DUR037, and + # `temporal.journal: false` is what keeps the two apart. extensions: intl, gd, pdo_mysql, mbstring, zip, curl, sodium, exif, fileinfo, grpc coverage: none - # Les paquets du dépôt entrent par des dépôts `path` : la boutique lit `../src`, donc - # elle teste le code de ce commit et non une version publiée. + # The repository's packages come in through `path` repositories: the shop reads + # `../src`, so it tests the code of this commit and not a published version. - name: Composer install (sylius/) working-directory: sylius run: composer install --no-interaction --prefer-dist --no-progress @@ -212,7 +214,7 @@ jobs: working-directory: sylius run: vendor/bin/phpunit tests/Unit - - name: Base de test + - name: Test database working-directory: sylius env: APP_ENV: test @@ -220,41 +222,41 @@ jobs: bin/console doctrine:database:create --if-not-exists bin/console doctrine:schema:create - - name: PHPUnit — le tableau de bord est rendu + - name: PHPUnit — the dashboard is rendered working-directory: sylius env: APP_ENV: test run: vendor/bin/phpunit tests/Functional/DurableDashboardTest.php magento-matrix: - # Le pendant de la matrice Symfony, pour l'autre hôte à conteneur étranger. Même principe : - # chaque entrée est un **bord qui a une raison**, pas un produit cartésien. Ce que le job - # prouve est la résolution — que la contrainte du module (`php >=8.2`, - # `magento/framework ^103.0`) est honnête face à ce que chaque ligne de Mage-OS exige. Ce - # qu'il ne prouve pas est le démarrage : installer la distribution coûte ~1 Go et plusieurs - # minutes par entrée, et c'est le travail du job d'intégration, pas d'une matrice. + # The counterpart of the Symfony matrix, for the other foreign-container host. Same + # principle: every entry is an **edge that has a reason**, not a cartesian product. What + # the job proves is the resolution — that the module's constraint (`php >=8.2`, + # `magento/framework ^103.0`) is honest against what each Mage-OS line requires. What it + # does not prove is the boot: installing the distribution costs ~1 GB and several minutes + # per entry, and that is the integration job's work, not a matrix's. # - # Le garde discrimine, vérifié en local avant d'être écrit ici : 2.2.0 sur PHP 8.2 résout - # (code 0), 3.4.0 sur PHP 8.2 échoue en nommant la cause — « requires php ~8.3.0||~8.4.0|| - # ~8.5.0 -> your php version (8.2.33) does not satisfy that requirement ». - name: Module Magento (Mage-OS ${{ matrix.mageos }} / PHP ${{ matrix.php-version }}) + # The guard discriminates, verified locally before being written here: 2.2.0 on PHP 8.2 + # resolves (code 0), 3.4.0 on PHP 8.2 fails naming the cause — "requires php ~8.3.0||~8.4.0|| + # ~8.5.0 -> your php version (8.2.33) does not satisfy that requirement". + name: Magento module (Mage-OS ${{ matrix.mageos }} / PHP ${{ matrix.php-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - # La ligne la plus ancienne qui accepte encore le plancher PHP du module. - # Elle éprouve `magento/framework: ^103.0` par le bas. + # The oldest line that still accepts the module's PHP floor. + # It puts `magento/framework: ^103.0` to the test from below. - { php-version: '8.2', mageos: '1.3.0' } - # L'épingle du banc, au plancher PHP : le couple sur lequel toutes les mesures - # de ce change ont été faites. + # The bench's pin, at the PHP floor: the pair on which every measurement in + # this change was made. - { php-version: '8.2', mageos: '2.2.0' } - # Haut de la ligne 2 sous un PHP récent. + # Top of the 2 line under a recent PHP. - { php-version: '8.4', mageos: '2.3.0' } - # Le bord qui mord : Mage-OS 3 refuse PHP 8.2 alors que le module l'autorise. - # 8.3 est donc le plancher *atteignable* de cette ligne, pas celui du module. + # The edge that bites: Mage-OS 3 refuses PHP 8.2 while the module allows it. + # 8.3 is therefore this line's *reachable* floor, not the module's. - { php-version: '8.3', mageos: '3.4.0' } - # Le plus récent sur le plus récent. + # The most recent on the most recent. - { php-version: '8.5', mageos: '3.4.0' } steps: - uses: actions/checkout@v4 @@ -263,14 +265,14 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - # Ce que le métapaquet réclame de l'hôte ; `magento/check-php-extensions.sh` en - # est la liste de référence, tenue à jour par le banc. + # What the metapackage demands of the host; `magento/check-php-extensions.sh` is + # the reference list for it, kept up to date by the bench. extensions: bcmath, ctype, curl, dom, gd, iconv, intl, mbstring, openssl, pdo_mysql, simplexml, soap, sodium, xsl, zip coverage: none - # Un projet jetable plutôt que le banc : `magento/` porte un lock épinglé sur une seule - # ligne, et une matrice qui le réutiliserait ne testerait qu'elle. - - name: Projet de résolution + # A throwaway project rather than the bench: `magento/` carries a lock pinned to a + # single line, and a matrix reusing it would test only that line. + - name: Resolution project run: | mkdir -p /tmp/resolve cat > /tmp/resolve/composer.json <- --health-cmd="mysqladmin ping -h 127.0.0.1" --health-interval=5s --health-timeout=5s --health-retries=20 @@ -336,8 +338,8 @@ jobs: image: opensearchproject/opensearch:2.12.0 env: discovery.type: single-node - # Le nom que 2.12 comprend — le banc l'a réglé ainsi, et c'est le réglage - # dont on sait qu'il marche. `plugins.security.disabled` est ignoré ici. + # The name 2.12 understands — the bench set it this way, and it is the setting + # we know works. `plugins.security.disabled` is ignored here. DISABLE_SECURITY_PLUGIN: 'true' OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m' ports: @@ -351,13 +353,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - # Le plancher du banc, et celui sur lequel toutes ses mesures ont été faites. + # The bench's floor, and the one on which all its measurements were made. php-version: '8.2' extensions: bcmath, ctype, curl, dom, gd, iconv, intl, mbstring, openssl, pdo_mysql, simplexml, soap, sodium, xsl, zip ini-values: memory_limit=-1 coverage: none - # Sans ce cache, chaque exécution retélécharge la distribution. + # Without this cache, every execution re-downloads the distribution. - name: Cache Composer uses: actions/cache@v4 with: @@ -365,48 +367,50 @@ jobs: key: magento-boot-${{ hashFiles('magento/composer.lock') }} restore-keys: magento-boot- - - name: MySQL accepte les déclencheurs + - name: MySQL accepts triggers run: mysql -h 127.0.0.1 -uroot -pmagento -e "SET GLOBAL log_bin_trust_function_creators = 1;" - # Les dépôts `path` de `magento/` lisent `../src` : le banc installe **ce commit**, pas - # une version publiée. C'est ce qui rend ce job capable de dire quelque chose du module. + # `magento/`'s `path` repositories read `../src`: the bench installs **this commit**, + # not a published version. That is what makes this job able to say something about + # the module. - name: Composer install (magento/) working-directory: magento run: composer install --no-interaction --prefer-dist --no-progress - # L'analyse statique du module vit **ici**, et pas dans le job qui la porte pour les - # autres paquets. La raison est celle qui a fait exister ce job : le module parle à des - # classes que le dépôt n'installe pas, et la seule machine qui les a est celle qui vient - # de payer l'installation de la distribution. L'alternative — des bouchons écrits à la - # main pour les seize classes de Magento que le module touche — n'aurait pas fait - # d'erreur : elle en aurait caché, parce qu'un bouchon approximatif rend l'analyse - # d'accord avec lui-même. + # The module's static analysis lives **here**, and not in the job that carries it for + # the other packages. The reason is the one that made this job exist: the module talks + # to classes the repository does not install, and the only machine that has them is + # the one that has just paid for installing the distribution. The alternative — stubs + # written by hand for the sixteen Magento classes the module touches — would not have + # made an error: it would have hidden some, because an approximate stub makes the + # analysis agree with itself. # - # ⚠ `--ignore-platform-req=ext-grpc` : le pont Temporal exige l'extension en dur, et ce - # job ne l'installe pas. Analyser n'en a pas besoin — les classes `Grpc\` viennent du - # paquet Composer, et le pont n'est ici que *scanné*, jamais exécuté ni analysé. - - name: Composer install (racine, pour les outils d'analyse) + # ⚠ `--ignore-platform-req=ext-grpc`: the Temporal bridge requires the extension + # outright, and this job does not install it. Analysing does not need it — the `Grpc\` + # classes come from the Composer package, and here the bridge is only *scanned*, never + # run nor analysed. + - name: Composer install (root, for the analysis tools) run: composer install --no-interaction --prefer-dist --no-progress --ignore-platform-req=ext-grpc - - name: PHPStan (src/DurableModule, contre les vraies classes de Magento) + - name: PHPStan (src/DurableModule, against the real Magento classes) run: composer phpstan-magento - - name: Psalm (src/DurableModule, contre les vraies classes de Magento) + - name: Psalm (src/DurableModule, against the real Magento classes) run: composer psalm-magento - # ⚠ Le squelette n'est pas au dépôt — `magento/.gitignore` n'y garde que l'overlay, et - # le poste de développement l'avait obtenu une fois par `create-project`. Après un - # `composer install` nu, `bin/magento` n'existe donc pas. `mage-os/magento2-base` porte - # ces fichiers ; on les déploie comme le ferait le plugin d'installation, sans écraser - # ce que l'overlay a déjà posé (`-n`). - - name: Déployer le squelette Magento + # ⚠ The skeleton is not in the repository — `magento/.gitignore` keeps only the overlay + # there, and the development workstation had obtained it once through `create-project`. + # After a bare `composer install`, `bin/magento` therefore does not exist. + # `mage-os/magento2-base` carries those files; we deploy them the way the installer + # plugin would, without overwriting what the overlay has already laid down (`-n`). + - name: Deploy the Magento skeleton working-directory: magento run: | cp -Rn vendor/mage-os/magento2-base/. . || true test -f bin/magento chmod +x bin/magento - - name: Installation de Magento + - name: Magento install working-directory: magento run: | bin/magento setup:install \ @@ -418,29 +422,29 @@ jobs: --search-engine=opensearch --opensearch-host=127.0.0.1 --opensearch-port=9200 \ --backend-frontname=admin --no-interaction - - name: Le module est là + - name: The module is there working-directory: magento run: | - # Deux modules, et la distinction est le sujet : `Gplanchat_DurableModule` est le paquet - # publié — il ne déclare aucun workflow. `Gplanchat_DurableProbe` est le module du - # **banc**, qui porte la démonstration et les sondes, et qui n'ira jamais chez un - # utilisateur. C'est lui qui fournit `durable:demo` à l'étape suivante. + # Two modules, and the distinction is the point: `Gplanchat_DurableModule` is the + # published package — it declares no workflow. `Gplanchat_DurableProbe` is the + # **bench**'s module, which carries the demonstration and the probes, and which will + # never reach a user. It is the one that provides `durable:demo` to the next step. bin/magento module:enable Gplanchat_DurableModule Gplanchat_DurableProbe bin/magento setup:upgrade --keep-generated bin/magento module:status Gplanchat_DurableModule | tee /tmp/status.txt grep -q 'Module is enabled' /tmp/status.txt - # L'assertion qui vaut le prix du job : un workflow écrit une fois tourne dans Magento, - # et ses activités viennent des attributs de son contrat, pas de chaînes recopiées. - - name: Un workflow tourne dedans + # The assertion worth the price of the job: a workflow written once runs inside + # Magento, and its activities come from its contract's attributes, not copied strings. + - name: A workflow runs inside it working-directory: magento run: | bin/magento durable:demo ORD-4242 | tee /tmp/demo.txt grep -q "notify:charge:ORD-4242" /tmp/demo.txt grep -q "durable.demo.charge" /tmp/demo.txt - # Sans DSN, l'écran doit **dire** pourquoi il est vide plutôt que d'avoir l'air en panne. - - name: L'écran d'administration répond + # Without a DSN, the screen must **say** why it is empty rather than look broken. + - name: The admin screen responds working-directory: magento run: | php -S 127.0.0.1:8080 -t pub/ phpserver/router.php > /tmp/httpd.log 2>&1 & @@ -449,6 +453,8 @@ jobs: grep -qi 'Magento Admin' /tmp/login.html static-analysis: + # Do not rename: this is a required status check on `main`. Renaming it makes the required + # context never report, and every PR unmergeable until branch protection is updated too. name: Analyse statique (PHPStan + Psalm) runs-on: ubuntu-latest steps: @@ -457,8 +463,8 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - # grpc : le pont Temporal le requiert désormais en dur, `composer install` - # échoue sans lui. + # grpc: the Temporal bridge now requires it outright, `composer install` + # fails without it. php-version: '8.2' extensions: mbstring, sqlite3, pdo_sqlite, grpc coverage: none @@ -466,7 +472,7 @@ jobs: - name: Install Composer dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Versions outils (composer.lock) + - name: Tool versions (composer.lock) run: | composer show phpstan/phpstan --no-ansi | head -n 5 composer show vimeo/psalm --no-ansi | head -n 5 @@ -480,14 +486,14 @@ jobs: run: composer psalm symfony-sample: - name: App exemple Symfony (composer + PHPUnit) + name: Symfony sample app (composer + PHPUnit) runs-on: ubuntu-latest strategy: fail-fast: false matrix: php-version: ['8.2', '8.3', '8.4', '8.5'] symfony: ['7.4.*', '8.0.*'] - # Symfony 8 exige PHP 8.4+ : pas d'entrée 8.0 sous 8.4. + # Symfony 8 requires PHP 8.4+: no 8.0 entry below 8.4. exclude: - php-version: '8.2' symfony: '8.0.*' @@ -502,13 +508,14 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - # ext-grpc : le pont Temporal l'exige en dur, et le leg 8.0 résout (composer update) - # au lieu d'installer depuis le lock — la contrainte plateforme y est donc vérifiée. + # ext-grpc: the Temporal bridge requires it outright, and the 8.0 leg resolves + # (composer update) instead of installing from the lock — so the platform + # constraint is checked there. extensions: mbstring, sqlite3, pdo_sqlite, grpc coverage: none - # 7.4 : install, pour que le composer.lock versionné reste exercé (et scanné par Dependabot). - # 8.0 : update, SYMFONY_REQUIRE n'agissant qu'à la résolution, hors lock. + # 7.4: install, so the versioned composer.lock stays exercised (and scanned by Dependabot). + # 8.0: update, SYMFONY_REQUIRE only acting at resolution time, outside the lock. - name: Composer (symfony/, Symfony ${{ matrix.symfony }}) working-directory: symfony run: composer ${{ matrix.symfony == '7.4.*' && 'install' || 'update' }} --no-interaction --prefer-dist --no-progress @@ -543,7 +550,7 @@ jobs: run: vendor/bin/phpunit tests/unit/Bridge/Temporal/Worker/ coverage: - name: Couverture (PCOV) + name: Coverage (PCOV) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -559,41 +566,41 @@ jobs: - name: Install Composer dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Rapport texte (src/) + - name: Text report (src/) run: composer test:coverage temporal-integration: - name: Tests d'intégration Temporal (gRPC + Temporal auto-setup) + name: Temporal integration tests (gRPC + Temporal auto-setup) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup PHP (ext-grpc requis) + - name: Setup PHP (ext-grpc required) uses: shivammathur/setup-php@v2 with: php-version: '8.2' extensions: mbstring, sqlite3, pdo_sqlite, grpc coverage: none - - name: Installer les dépendances Composer (symfony/) + - name: Install Composer dependencies (symfony/) working-directory: symfony run: composer install --no-interaction --prefer-dist --no-progress - # `symfony/.env` publie la façade sur 7234, pour cohabiter avec un Temporal - # local sur 7233. En CI il n'y a rien à côtoyer : on publie sur 7233, le port - # que le DSN et les docblocs des tests annoncent. Sans ce pilotage, compose - # écoutait 7234, le DSN visait 7233, et les neuf tests qui parlent au serveur - # sautaient — le job restait vert en ne testant rien. - - name: Démarrer PostgreSQL + Temporal (symfony/compose.yaml) + # `symfony/.env` publishes the frontend on 7234, so it can sit next to a local + # Temporal on 7233. In CI there is nothing to sit next to: we publish on 7233, + # the port the DSN and the tests' docblocks announce. Without this steering, + # compose listened on 7234, the DSN aimed at 7233, and the nine tests that talk + # to the server were skipped — the job stayed green while testing nothing. + - name: Start PostgreSQL + Temporal (symfony/compose.yaml) working-directory: symfony env: TEMPORAL_FRONTEND_PORT: '7233' run: docker compose up -d --wait --wait-timeout 300 database temporal - - name: La façade répond, sinon on ne teste rien + - name: The frontend responds, otherwise we test nothing run: timeout 30 bash -c 'until nc -z 127.0.0.1 7233; do sleep 1; done' - - name: PHPUnit — tests d'intégration Temporal (tout le répertoire) + - name: PHPUnit — Temporal integration tests (the whole directory) working-directory: symfony env: DURABLE_DSN: 'temporal://127.0.0.1:7233?namespace=default&journal_task_queue=durable-journal&activity_task_queue=durable-activities&tls=0' @@ -603,29 +610,29 @@ jobs: --display-skipped \ tests/Integration/Temporal/ - - name: Arrêt des conteneurs + - name: Stop the containers if: always() working-directory: symfony run: docker compose down -v docker-compose-stack: - name: Stack Docker (Temporal + UI) + name: Docker stack (Temporal + UI) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Valider compose.yaml + - name: Validate compose.yaml run: docker compose -f symfony/compose.yaml config --quiet - - name: Démarrer PostgreSQL + Temporal + UI + - name: Start PostgreSQL + Temporal + UI run: docker compose -f symfony/compose.yaml up -d --wait --wait-timeout 180 database temporal temporal-ui - - name: Vérifier Temporal UI (exec interne) + - name: Check Temporal UI (internal exec) run: | set -e for i in $(seq 1 20); do if docker compose -f symfony/compose.yaml exec -T temporal-ui wget -qO- http://localhost:8080/ > /dev/null 2>&1; then - echo "Temporal UI OK (tentative $i)" + echo "Temporal UI OK (attempt $i)" exit 0 fi sleep 5 @@ -633,6 +640,6 @@ jobs: docker compose -f symfony/compose.yaml logs temporal-ui --tail 40 exit 1 - - name: Arrêt des conteneurs + - name: Stop the containers if: always() run: docker compose -f symfony/compose.yaml down -v From 2888636198159c15eabcc429e7f14ffb2c509f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Tue, 8 Sep 2026 00:01:40 +0200 Subject: [PATCH 4/6] docs(ci): write the docs and prises workflows in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs-ovh.yml` deploys the Hugo site to OVH; `prises.yml` runs the claim-registry check. Their `run:` blocks keep every command byte-identical — a diff of all non-comment, non-`name`, non-`echo` lines is empty. What did move inside them is what a person reads in a run log: the six `::error::` / `::warning::` annotations and one `echo`, plus the two-line shell comment explaining why `sshpass` feeds the password that `-u` ignores. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/docs-ovh.yml | 48 +++++++++++++++++----------------- .github/workflows/prises.yml | 27 ++++++++++--------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/.github/workflows/docs-ovh.yml b/.github/workflows/docs-ovh.yml index 402abbaa..caad67b3 100644 --- a/.github/workflows/docs-ovh.yml +++ b/.github/workflows/docs-ovh.yml @@ -24,8 +24,8 @@ permissions: contents: read env: - # Quota de l'hébergement mutualisé : 100 Mo. Le job échoue au-delà, plutôt que - # de téléverser à moitié et laisser un site cassé en ligne. + # Shared hosting quota: 100 MB. The job fails beyond it, rather than uploading + # half a site and leaving a broken one online. SITE_QUOTA_KB: 100000 jobs: @@ -43,27 +43,27 @@ jobs: hugo-version: '0.159.2' extended: true - # Le thème embarque mermaid, katex et asciinema — 4,4 Mo copiés dans la - # sortie même inutilisés. On les retire, mais seulement tant qu'aucune page - # ne les appelle : cette garde échoue le jour où ce ne sera plus vrai. - - name: Guard — aucune page n'utilise les bibliothèques élaguées + # The theme ships mermaid, katex and asciinema — 4.4 MB copied into the + # output even when unused. We strip them, but only for as long as no page + # calls them: this guard fails the day that stops being true. + - name: Guard — no page uses the pruned libraries run: | if grep -rlE '\{\{< *(mermaid|katex|math|asciinema)' documentation/user --include='*.md'; then - echo "::error::Une page utilise un shortcode dont la bibliothèque est élaguée au déploiement." - echo "Retirez le shortcode, ou retirez l'élagage dans l'étape « Prune »." + echo "::error::A page uses a shortcode whose library is pruned at deploy time." + echo "Remove the shortcode, or remove the pruning in the \"Prune\" step." exit 1 fi - - name: Guard — l'import refuserait d'écraser une correction à la main - # WA005 : trois correctifs écrits dans layouts/index.html et perdus, sans un bruit. - # La garde vit dans import-design.py ; ce pas ne vérifie que la garde elle-même, - # puisque l'import n'est jamais joué en CI — il n'y a pas de canevas ici. + - name: Guard — the import would refuse to overwrite a hand-written fix + # WA005: three fixes written into layouts/index.html and lost, without a sound. + # The guard lives in import-design.py; this step only checks the guard itself, + # since the import is never played in CI — there is no canvas here. run: python3 hugo-docs/import-design.py --self-test - - name: Guard — la page servie est bien celle du canevas - # WA005 point 2, écrivable depuis que le canevas est au dépôt : rejouer l'import - # et échouer sur la moindre différence. Une correction écrite dans layouts/ et pas - # dans le canevas rougit ici, avec son diff — avant la régénération, pas après. + - name: Guard — the page served is the one from the canvas + # WA005 point 2, writable since the canvas landed in the repository: replay the import + # and fail on the slightest difference. A fix written into layouts/ and not into the + # canvas goes red here, with its diff — before the regeneration, not after. run: | cd hugo-docs cp layouts/index.html /tmp/committed-en.html @@ -83,15 +83,15 @@ jobs: working-directory: hugo-docs/public run: | rm -rf mermaid.min.js katex asciinema - echo "Après élagage :" && du -sh . + echo "After pruning:" && du -sh . - - name: Guard — le site tient dans le quota + - name: Guard — the site fits within the quota working-directory: hugo-docs/public run: | SIZE_KB=$(du -sk . | cut -f1) echo "Site: ${SIZE_KB} Ko (quota ${SITE_QUOTA_KB} Ko)" if [ "$SIZE_KB" -gt "$SITE_QUOTA_KB" ]; then - echo "::error::Le site dépasse le quota d'hébergement (${SIZE_KB} Ko > ${SITE_QUOTA_KB} Ko)." + echo "::error::The site is over the hosting quota (${SIZE_KB} KB > ${SITE_QUOTA_KB} KB)." exit 1 fi @@ -124,7 +124,7 @@ jobs: SFTP_HOST: ${{ secrets.OVH_FTP_SERVER }} SFTP_USER: ${{ secrets.OVH_FTP_USERNAME }} SSHPASS: ${{ secrets.OVH_FTP_PASSWORD }} - # Racine web de l'hébergement mutualisé OVH. + # Web root of the OVH shared hosting. SERVER_DIR: ${{ vars.OVH_FTP_ROOT || './www/' }} KNOWN_HOSTS: ${{ secrets.OVH_SSH_KNOWN_HOSTS }} run: | @@ -133,16 +133,16 @@ jobs: if [ -n "$KNOWN_HOSTS" ]; then printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts else - echo "::warning::OVH_SSH_KNOWN_HOSTS absent — empreinte du serveur acceptée à l'aveugle." + echo "::warning::OVH_SSH_KNOWN_HOSTS is absent — the server fingerprint is accepted blind." ssh-keyscan -H "$SFTP_HOST" > ~/.ssh/known_hosts 2>/dev/null fi if [ ! -s ~/.ssh/known_hosts ]; then - echo "::error::Aucune clé d'hôte pour $SFTP_HOST — SSH/SFTP est-il activé sur l'hébergement ?" + echo "::error::No host key for $SFTP_HOST — is SSH/SFTP enabled on the hosting?" exit 1 fi echo "Cible : $SFTP_USER@$SFTP_HOST:$SERVER_DIR" - # lftp délègue SFTP à ssh, qui n'accepte pas de mot de passe sur stdin : - # sshpass le fournit depuis $SSHPASS. Le mot de passe passé à -u est ignoré. + # lftp delegates SFTP to ssh, which does not accept a password on stdin: sshpass + # supplies it from $SSHPASS. The password passed to -u is ignored. lftp -u "$SFTP_USER",ignored "sftp://$SFTP_HOST" -e " set sftp:connect-program 'sshpass -e ssh -a -x -o StrictHostKeyChecking=yes'; mirror --reverse --delete --verbose ./site/ '$SERVER_DIR'; diff --git a/.github/workflows/prises.yml b/.github/workflows/prises.yml index 7809e6a2..e9adbed8 100644 --- a/.github/workflows/prises.yml +++ b/.github/workflows/prises.yml @@ -1,14 +1,15 @@ -name: Registre des prises +name: Claims registry -# Deux déclencheurs, deux rôles distincts. +# Two triggers, two distinct roles. # -# `pull_request` **filtré sur `.worktrees/prises/**`** : une session qui pose ou retire une prise -# est contrôlée sur-le-champ. Le filtre est essentiel — sans lui, une prise oubliée par quelqu'un -# d'autre bloquerait des PR qui n'y sont pour rien, et un check qui punit l'innocent finit désarmé. +# `pull_request` **filtered on `.worktrees/prises/**`**: a session that places or releases a claim +# is checked on the spot. The filter is essential — without it, a claim someone else forgot would +# block PRs that have nothing to do with it, and a check that punishes the innocent ends up +# disarmed. # -# `schedule` : le retrait oublié ne se voit dans aucune PR, par définition. C'est le balayage -# quotidien qui l'attrape. Un échec de workflow planifié notifie le propriétaire du dépôt, ce qui -# est le seul canal qui ne demande à personne d'aller regarder. +# `schedule`: a forgotten release shows up in no PR, by definition. It is the daily sweep that +# catches it. A scheduled workflow failure notifies the repository owner, which is the only channel +# that asks nobody to go and look. on: pull_request: paths: @@ -30,17 +31,17 @@ concurrency: jobs: registre: - name: Prises périmées et titres + name: Stale claims and titles runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # Avant d'interroger le distant : le contrôle se contrôle lui-même, hors-ligne. Sa première - # version déclarait périmée une prise vivante, et rien ne l'avait vu venir. - - name: Contrôler le contrôle + # Before querying the remote: the check checks itself, offline. Its first version declared a + # live claim stale, and nothing had seen it coming. + - name: Check the check run: bin/prises-check-test.sh - - name: Contrôler le registre + - name: Check the registry env: GH_TOKEN: ${{ github.token }} run: bin/prises-check.sh "${{ github.repository }}" From f6501f8d148a8d84b641cb54ac3a0af20c8b0d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Tue, 8 Sep 2026 00:01:40 +0200 Subject: [PATCH 5/6] docs(ci): write the php-grpc image workflow in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly build of the sixteen `php-grpc` images and the pruning of their dated tags. Beyond comments and step names, three kinds of text moved, none of them executable: the two `workflow_dispatch` input descriptions, which are the help GitHub shows when a human runs the workflow by hand; the `::warning::` that fires when `GHCR_RETENTION_TOKEN` is absent; and the dry-run notice. The four `run:` blocks are identical, and the shell comment about the two package URL families moved with the prose it belongs to. The header's cross-reference to the `Tests d'intégration Temporal` check follows that job's rename in `ci.yml`, in the commit below. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/php-grpc-images.yml | 121 +++++++++++++------------- 1 file changed, 60 insertions(+), 61 deletions(-) diff --git a/.github/workflows/php-grpc-images.yml b/.github/workflows/php-grpc-images.yml index dacd5ecd..e30ef399 100644 --- a/.github/workflows/php-grpc-images.yml +++ b/.github/workflows/php-grpc-images.yml @@ -1,43 +1,43 @@ -# Construit les images PHP portant `grpc` et `protobuf`, et les publie sur GHCR. +# Builds the PHP images carrying `grpc` and `protobuf`, and publishes them to GHCR. # -# **Indépendante de `ci.yml` à dessein.** Elle ne teste rien du composant : elle fabrique un -# outillage, sur son propre rythme et avec ses propres permissions. La mêler à la CI ferait payer -# une compilation C++ de plusieurs minutes à chaque pull request qui touche une ligne de PHP. +# **Deliberately independent of `ci.yml`.** It tests nothing of the component: it builds tooling, +# on its own rhythm and with its own permissions. Mixing it into CI would charge a multi-minute C++ +# compilation to every pull request that touches a line of PHP. # -# **Pourquoi ces images existent, alors que la CI n'en a pas besoin.** Mesuré sur ce dépôt : -# `shivammathur/setup-php` installe `grpc` en **cinq secondes**, binaire préconstruit à l'appui — -# le job « Tests d'intégration Temporal » démarre PHP aussi vite que celui de Sylius, qui ne demande -# pas l'extension. Pour GitHub Actions, le problème est réglé et une chaîne de construction serait -# une redite. +# **Why these images exist, when CI does not need them.** Measured on this repository: +# `shivammathur/setup-php` installs `grpc` in **five seconds**, on the strength of a prebuilt +# binary — the "Temporal integration tests" job starts PHP as fast as the Sylius one, which does +# not ask for the extension. For GitHub Actions the problem is settled, and a build chain would be +# a restatement. # -# Ce qui n'est pas réglé, c'est **Docker**. Une image `php:8.3-alpine` n'a pas grpc, et -# `install-php-extensions` la compile depuis les sources à chaque construction. Des images tierces -# existent, maintenues par une personne et hors de notre contrôle. D'où celles-ci : le coût est payé -# une fois, ici, et ce qu'on consomme nous appartient. +# What is not settled is **Docker**. A `php:8.3-alpine` image has no grpc, and +# `install-php-extensions` compiles it from source on every build. Third-party images exist, +# maintained by one person and outside our control. Hence these: the cost is paid once, here, and +# what we consume belongs to us. -name: Images PHP (grpc + protobuf) +name: PHP images (grpc + protobuf) on: workflow_dispatch: inputs: platforms: - description: "Architectures cibles. arm64 passe par QEMU et rallonge beaucoup une compilation C++." + description: "Target architectures. arm64 goes through QEMU and lengthens a C++ compilation considerably." default: linux/amd64 type: choice options: - linux/amd64 - linux/amd64,linux/arm64 dry_run: - description: "Élagage : montrer ce qui serait retiré, sans le retirer." + description: "Prune: show what would be removed, without removing it." default: true type: boolean - # Déclenchement externe, pour qui veut rafraîchir sans passer par l'interface : + # External trigger, for whoever wants to refresh without going through the interface: # gh api repos/:owner/:repo/dispatches -f event_type=php-grpc-images repository_dispatch: types: [php-grpc-images] push: - # `main` seulement. Sans ce filtre, une branche de travail publie ses images sous les - # étiquettes glissantes — `8.3-cli` finit par désigner un essai non relu. + # `main` only. Without this filter, a working branch publishes its images under the + # rolling tags — `8.3-cli` ends up designating an unreviewed experiment. branches: [main] paths: - 'docker/php-grpc/**' @@ -47,10 +47,10 @@ on: - 'docker/php-grpc/**' - '.github/workflows/php-grpc-images.yml' schedule: - # Toutes les semaines. Deux choses vieillissent ici et pas au même rythme : les paquets - # système de la base, et PHP lui-même — dont les versions correctives sortent chaque mois. - # Une cadence hebdomadaire fait que l'écart entre `php:8.4-cli` et notre image se compte en - # jours. Le cache Buildx rend la reconstruction bon marché quand rien n'a bougé en amont. + # Every week. Two things age here, and not at the same rate: the base's system packages, + # and PHP itself — whose patch releases come out every month. A weekly cadence keeps the + # gap between `php:8.4-cli` and our image counted in days. The Buildx cache makes the + # rebuild cheap when nothing has moved upstream. - cron: '17 4 * * 1' concurrency: @@ -68,11 +68,10 @@ jobs: fail-fast: false matrix: php: ['8.2', '8.3', '8.4', '8.5'] - # `zts` est la variante compilée avec le support des threads. Elle n'est pas un - # détail de compilation : un runtime qui exécute plusieurs workers dans un même - # processus la réclame, et une extension chargée dans un PHP dont la thread-safety - # ne correspond pas refuse tout simplement de se charger. Les deux sont donc - # nécessaires, aucune ne remplace l'autre. + # `zts` is the variant compiled with thread support. It is not a compilation + # detail: a runtime that runs several workers in a single process demands it, and + # an extension loaded into a PHP whose thread-safety does not match simply refuses + # to load. Both are therefore necessary, neither one replaces the other. flavour: [cli, cli-alpine, zts, zts-alpine] steps: - uses: actions/checkout@v4 @@ -82,22 +81,22 @@ jobs: - uses: docker/setup-buildx-action@v3 - - name: S'authentifier auprès de GHCR + - name: Authenticate against GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # `github.repository_owner` plutôt que le nom du dépôt : ces images ne sont pas celles de - # Durable, ce sont celles de son auteur — d'autres projets peuvent les consommer. - - name: Étiquettes + # `github.repository_owner` rather than the repository name: these images are not + # Durable's, they are its author's — other projects can consume them. + - name: Tags id: meta run: | base="ghcr.io/${{ github.repository_owner }}/php-grpc" echo "tags=${base}:${{ matrix.php }}-${{ matrix.flavour }},${base}:${{ matrix.php }}-${{ matrix.flavour }}-$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT" - - name: Construire et publier + - name: Build and publish uses: docker/build-push-action@v6 with: context: docker/php-grpc @@ -105,16 +104,16 @@ jobs: build-args: | PHP_VERSION=${{ matrix.php }} FLAVOUR=${{ matrix.flavour }} - # Sur une pull request, on construit sans publier : la compilation est ce qu'on - # veut vérifier, la publication n'a pas à l'être. + # On a pull request we build without publishing: the compilation is what we + # want to check, the publication does not have to be. push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha,scope=php-grpc-${{ matrix.php }}-${{ matrix.flavour }} cache-to: type=gha,mode=max,scope=php-grpc-${{ matrix.php }}-${{ matrix.flavour }} - # Le `RUN php -m | grep` du Dockerfile prouve l'extension **dans** la couche construite. - # Celui-ci la prouve dans l'image **publiée**, ce qui n'est pas la même affirmation. - - name: L'image publiée porte bien les deux extensions + # The Dockerfile's `RUN php -m | grep` proves the extension **in** the built layer. + # This one proves it in the **published** image, which is not the same assertion. + - name: The published image does carry both extensions if: github.event_name != 'pull_request' && !contains(inputs.platforms, 'arm64') run: | image="ghcr.io/${{ github.repository_owner }}/php-grpc:${{ matrix.php }}-${{ matrix.flavour }}" @@ -124,46 +123,46 @@ jobs: docker run --rm "$image" php -i | grep -q "Thread Safety => $attendu" docker run --rm "$image" php -v | head -n 1 - # Seize images reconstruites chaque lundi, chacune posant une étiquette datée : environ huit - # cents étiquettes par an, et rien ne les élague. La liste des versions devient illisible bien - # avant que le stockage ne pose problème. + # Sixteen images rebuilt every Monday, each placing a dated tag: about eight hundred tags a + # year, and nothing prunes them. The version list becomes unreadable well before storage + # becomes a problem. retention: - name: Élaguer les étiquettes datées + name: Prune the dated tags needs: build - # Jamais sur une pull request : rien n'y est publié, il n'y a donc rien à élaguer, et un job - # qui supprime des images publiées n'a pas sa place dans la validation d'une proposition. + # Never on a pull request: nothing is published there, so there is nothing to prune, and + # a job that deletes published images has no place in validating a proposal. if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # Ce qui décide tient dans une fonction, et elle est vérifiée avant d'être appliquée. - # Le cas qui compte : le lundi de la publication, `8.4-zts` et `8.4-zts-20260828` - # désignent le même manifeste — retirer « l'étiquette datée » retirerait l'image que - # l'étiquette glissante désigne. - - name: La règle se vérifie avant de s'appliquer + # What decides fits in one function, and it is checked before being applied. + # The case that matters: on publication Monday, `8.4-zts` and `8.4-zts-20260828` + # designate the same manifest — removing "the dated tag" would remove the image the + # rolling tag designates. + - name: The rule checks itself before it applies run: python3 docker/php-grpc/retention.py --self-test - - name: Élaguer + - name: Prune env: - # L'API de suppression de paquets n'accepte pas le `GITHUB_TOKEN` : elle demande - # un jeton personnel classique portant `read:packages` et `delete:packages`. Sans - # le secret, le job avertit et s'arrête — un échec hebdomadaire finirait par être - # ignoré, et avec lui les vrais. + # The package deletion API does not accept the `GITHUB_TOKEN`: it asks for a + # classic personal token carrying `read:packages` and `delete:packages`. Without + # the secret, the job warns and stops — a weekly failure would end up ignored, + # and the real ones with it. GH_TOKEN: ${{ secrets.GHCR_RETENTION_TOKEN }} OWNER: ${{ github.repository_owner }} DRY_RUN: ${{ inputs.dry_run || 'false' }} run: | if [ -z "$GH_TOKEN" ]; then - echo "::warning title=Élagage non configuré::Le secret GHCR_RETENTION_TOKEN est absent. Les étiquettes datées continuent de s'accumuler. Créez un jeton personnel classique avec read:packages et delete:packages." + echo "::warning title=Pruning not configured::The GHCR_RETENTION_TOKEN secret is absent. Dated tags keep accumulating. Create a classic personal token with read:packages and delete:packages." exit 0 fi # Deux familles d'URL cohabitent pour les paquets : celle de l'utilisateur - # authentifié, et celle d'un utilisateur nommé. Un jeton appartenant au - # propriétaire du paquet passe par la première ; on essaie, et on retombe sur la - # seconde. Résoudre ici plutôt qu'à la suppression a son importance : la lecture a - # lieu chaque semaine, la première suppression n'est due que dans deux mois. + # authenticated user, and that of a named user. A token belonging to the + # package's owner goes through the first; we try it, and fall back on the second. + # Resolving here rather than at deletion time matters: the read happens every + # week, the first deletion is only due in two months. base="/user/packages/container/php-grpc" if ! gh api "$base/versions?per_page=1" >/dev/null 2>&1; then base="/users/$OWNER/packages/container/php-grpc" @@ -178,11 +177,11 @@ jobs: python3 docker/php-grpc/retention.py --keep 8 < versions.json > a-retirer.txt if [ "$DRY_RUN" = "true" ]; then - echo "::notice::Essai à blanc : $(wc -l < a-retirer.txt) version(s) auraient été retirées." + echo "::notice::Dry run: $(wc -l < a-retirer.txt) version(s) would have been removed." exit 0 fi while read -r id; do gh api -X DELETE "$base/versions/$id" - echo "retirée : $id" + echo "removed: $id" done < a-retirer.txt From d5cb4ab1e0b8c2d0da9cf8fb9f9802f245a90269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Tue, 8 Sep 2026 00:01:40 +0200 Subject: [PATCH 6/6] docs(ci): write the php-grpc image sources in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dockerfile, the README that documents the sixteen images, and `retention.py`. The Dockerfile's non-comment lines are byte-identical: no instruction, package, version, `ARG` or `ENV` moved. `retention.py` decides which published images may be deleted, so it got the most care. Its module docblock — which is also its `--help` — its two docstrings and its four printed messages are English now, and so are its identifiers: `a_supprimer` is `to_delete`, `DATEE` is `DATED`, `surnuméraires` is `surplus`. They are all module-local; the workflow calls the script, not its functions. Its own `--self-test` passes, and a hand-run over a three-version fixture still returns exactly the one dated version beyond the keep count. One French line stays, inside a README code fence: `real 6m58s image finale : 126 Mo`. It is a recorded measurement, and a fence is a transcript. Co-Authored-By: Claude Opus 5 (1M context) --- docker/php-grpc/Dockerfile | 35 ++++--- docker/php-grpc/README.md | 186 +++++++++++++++++------------------ docker/php-grpc/retention.py | 137 +++++++++++++------------- 3 files changed, 177 insertions(+), 181 deletions(-) diff --git a/docker/php-grpc/Dockerfile b/docker/php-grpc/Dockerfile index a9a8ffd8..defb8491 100644 --- a/docker/php-grpc/Dockerfile +++ b/docker/php-grpc/Dockerfile @@ -1,37 +1,36 @@ # syntax=docker/dockerfile:1 -# Une image PHP qui porte déjà `grpc` et `protobuf`. +# A PHP image that already carries `grpc` and `protobuf`. # -# Ces deux extensions se compilent depuis les sources. Mesuré pour `8.3-cli-alpine` sur une machine -# de développement : **6 min 58 s**, pour 126 Mo d'image — grpc 1.83 traîne abseil, boringssl, re2 et -# upb, et se compile en C++17. `install-php-extensions` ne fait pas de miracle : il fait le travail -# correctement, pas instantanément. Le payer une fois en CI et le publier, c'est tout l'objet ici. +# Both extensions compile from source. Measured for `8.3-cli-alpine` on a development machine: +# **6 min 58 s**, for a 126 MB image — grpc 1.83 drags abseil, boringssl, re2 and upb along, and +# compiles as C++17. `install-php-extensions` works no miracle: it does the job properly, not +# instantly. Paying for it once in CI and publishing the result is the whole point here. # -# **Ce n'est pas une image d'application.** Elle ne contient ni votre code, ni Composer, ni un -# serveur : elle sert de base, ou de source pour un `COPY --from` dans un montage multi-étapes. +# **This is not an application image.** It contains neither your code, nor Composer, nor a +# server: it serves as a base, or as the source of a `COPY --from` in a multi-stage build. ARG PHP_VERSION=8.3 ARG FLAVOUR=cli FROM php:${PHP_VERSION}-${FLAVOUR} -# Le script de mlocati plutôt qu'un `pecl install` écrit à la main : il connaît les paquets système -# de chaque distribution, nettoie derrière lui, et suit les versions de PHP au fil des sorties. +# mlocati's script rather than a hand-written `pecl install`: it knows each distribution's system +# packages, cleans up behind itself, and follows PHP versions as they are released. COPY --from=mlocati/php-extension-installer:latest /usr/bin/install-php-extensions /usr/local/bin/ -# `protobuf` n'est pas décoratif à côté de `grpc` : sans lui, la sérialisation retombe sur -# l'implémentation PHP pure, correcte mais nettement plus lente sur des messages volumineux. +# `protobuf` is not decorative next to `grpc`: without it, serialisation falls back on the pure +# PHP implementation, correct but markedly slower on large messages. RUN install-php-extensions grpc protobuf -# Une image dont on ne peut pas vérifier le contenu ne sert à rien : ces deux lignes échouent au -# build si l'extension n'est pas réellement chargeable, plutôt que de laisser le défaut au -# consommateur. +# An image whose contents cannot be checked is of no use: these two lines fail the build if the +# extension is not genuinely loadable, rather than leaving the fault to the consumer. RUN php -m | grep -qx grpc && php -m | grep -qx protobuf -# Et la thread-safety doit correspondre à l'étiquette qu'on s'apprête à poser. Une extension -# compilée pour un PHP non thread-safe refuse de se charger dans un PHP thread-safe : publier un -# binaire NTS sous `…-zts` parce que la base a été mal résolue donnerait une image verte et -# inutilisable. `php -m` ne dit rien de cet axe-là, il faut le demander séparément. +# And thread-safety must match the tag we are about to place. An extension compiled for a +# non-thread-safe PHP refuses to load into a thread-safe PHP: publishing an NTS binary under +# `…-zts` because the base resolved wrongly would give a green, unusable image. `php -m` says +# nothing about that axis, it has to be asked separately. ARG FLAVOUR RUN attendu="$(case "$FLAVOUR" in zts*) echo enabled;; *) echo disabled;; esac)" \ && php -i | grep -q "Thread Safety => $attendu" diff --git a/docker/php-grpc/README.md b/docker/php-grpc/README.md index e6a3f008..c6ec7528 100644 --- a/docker/php-grpc/README.md +++ b/docker/php-grpc/README.md @@ -1,25 +1,25 @@ # `ghcr.io/gplanchat/php-grpc` -Des images PHP qui portent déjà **`grpc`** et **`protobuf`**, construites une fois en CI plutôt -qu'à chaque `docker build` d'un projet. +PHP images that already carry **`grpc`** and **`protobuf`**, built once in CI rather than on every +`docker build` of a project. ``` ghcr.io/gplanchat/php-grpc:8.3-cli ghcr.io/gplanchat/php-grpc:8.3-zts ghcr.io/gplanchat/php-grpc:8.3-cli-alpine ghcr.io/gplanchat/php-grpc:8.3-zts-alpine ``` -PHP 8.2, 8.3, 8.4 et 8.5, en Debian et en Alpine, **avec et sans thread-safety**. Chaque -publication pose aussi une étiquette datée (`8.3-cli-alpine-20260828`) pour qui veut épingler. +PHP 8.2, 8.3, 8.4 and 8.5, on Debian and on Alpine, **with and without thread-safety**. Each +publication also places a dated tag (`8.3-cli-alpine-20260828`) for whoever wants to pin. -Les variantes `zts` ne sont pas un raffinement : une extension compilée pour un PHP non thread-safe -refuse de se charger dans un PHP thread-safe, et réciproquement. Un runtime qui exécute plusieurs -workers dans un même processus réclame `zts` ; le reste du monde PHP tourne en `cli`. Aucune des -deux ne couvre l'autre, d'où les quatre déclinaisons. +The `zts` variants are not a refinement: an extension compiled for a non-thread-safe PHP refuses to +load into a thread-safe PHP, and the other way round. A runtime that runs several workers in a +single process demands `zts`; the rest of the PHP world runs on `cli`. Neither of the two covers the +other, hence the four variants. -## Ce que ces images ne sont pas +## What these images are not -**Pas des images d'application.** Ni votre code, ni Composer, ni serveur web. Elles servent de base, -ou de source dans un montage multi-étapes : +**Not application images.** Neither your code, nor Composer, nor a web server. They serve as a base, +or as a source in a multi-stage build: ```dockerfile FROM ghcr.io/gplanchat/php-grpc:8.3-cli-alpine AS ext @@ -33,109 +33,107 @@ COPY --from=ext /usr/local/lib/php/extensions/no-debug-non-zts-20230831/protobuf RUN php -m | grep -qx grpc && php -m | grep -qx protobuf ``` -Le chemin est nommé en entier plutôt que copié en bloc, à dessein : `COPY` du dossier -`extensions/` réussit même quand le nom du dossier de la base est différent — le `.so` atterrit dans -un dossier que PHP ne lit pas, et la panne attend l'exécution. Le chemin explicite échoue au +The path is named in full rather than copied wholesale, and deliberately so: a `COPY` of the +`extensions/` directory succeeds even when the base's directory name differs — the `.so` lands in a +directory PHP does not read, and the failure waits for runtime. The explicit path fails at `docker build`. -Trois choses doivent correspondre entre les deux images : la **version mineure** de PHP, la -**thread-safety**, et la **libc**. Les deux premières sont dans le nom du dossier, donc un écart -casse la construction. La troisième n'y est pas : Debian et Alpine partagent le même chemin, un -`.so` musl se copie sans un mot dans une base glibc et refuse de se charger ensuite. D'où le -`RUN php -m` final, qui rattrape ce cas-là et lui seul. +Three things must match between the two images: PHP's **minor version**, the **thread-safety**, and +the **libc**. The first two are in the directory name, so a mismatch breaks the build. The third is +not there: Debian and Alpine share the same path, a musl `.so` copies without a word into a glibc +base and then refuses to load. Hence the final `RUN php -m`, which catches that case and only that +one. -Une quatrième condition ne se voit nulle part dans les chemins : grpc est du C++ et réclame -`libstdc++`. Les bases Debian l'embarquent toutes ; les bases Alpine, pas toutes — l'image Sylius -ci-dessus l'a, `php:8.3-fpm-alpine` ne l'a pas et demande un `apk add --no-cache libstdc++`. Là -encore, c'est le `RUN php -m` qui le dit. +A fourth condition shows up nowhere in the paths: grpc is C++ and demands `libstdc++`. The Debian +bases all ship it; the Alpine bases, not all — the Sylius image above has it, `php:8.3-fpm-alpine` +does not and needs an `apk add --no-cache libstdc++`. There again, it is the `RUN php -m` that says +so. -**Le guide utilisateur détaille tout cela** — recettes pour php-fpm derrière Nginx ou Caddy, pour -Apache avec mod_php, et pour FrankenPHP (qui est thread-safe, donc `zts`) : +**The user guide covers all of this** — recipes for php-fpm behind Nginx or Caddy, for Apache with +mod_php, and for FrankenPHP (which is thread-safe, hence `zts`): . -## Ce que ces images ne servent pas à résoudre +## What these images are not there to solve -**GitHub Actions n'en a pas besoin.** Mesuré sur ce dépôt : `shivammathur/setup-php` installe -`grpc` en **cinq secondes**, binaire préconstruit à l'appui. Le job « Tests d'intégration Temporal », -qui demande l'extension, démarre PHP aussi vite que celui de Sylius, qui ne la demande pas : +**GitHub Actions does not need them.** Measured on this repository: `shivammathur/setup-php` +installs `grpc` in **five seconds**, on the strength of a prebuilt binary. The "Temporal +integration tests" job, which asks for the extension, starts PHP as fast as the Sylius one, which +does not: -| job | étape « Setup PHP » | demande `grpc` | +| job | "Setup PHP" step | asks for `grpc` | |---|---|---| -| Tests d'intégration Temporal | 5 s | oui | -| Boutique Sylius | 6 s | non | +| Temporal integration tests | 5 s | yes | +| Sylius shop | 6 s | no | -Dans un workflow, écrivez donc `extensions: …, grpc` et n'y pensez plus. Ces images sont pour -**Docker**, où `install-php-extensions` compile depuis les sources à chaque construction. +In a workflow, then, write `extensions: …, grpc` and think no more of it. These images are for +**Docker**, where `install-php-extensions` compiles from source on every build. -**Combien de temps, exactement.** Mesuré en construisant `8.3-cli-alpine` sur une machine de -développement ordinaire : +**How long, exactly.** Measured by building `8.3-cli-alpine` on an ordinary development machine: ``` real 6m58s image finale : 126 Mo ``` -Sept minutes par construction, par version de PHP, par distribution, sur chaque machine et dans -chaque pipeline qui en a besoin. C'est ce que publier l'image supprime — grpc 1.83 traînant derrière -lui abseil, boringssl, re2 et upb, et se compilant en C++17. +Seven minutes per build, per PHP version, per distribution, on every machine and in every pipeline +that needs it. That is what publishing the image removes — grpc 1.83 dragging abseil, boringssl, re2 +and upb behind it, and compiling as C++17. -## Comment elles sont construites +## How they are built -`install-php-extensions` de mlocati plutôt qu'un `pecl install` écrit à la main : il connaît les -paquets système de chaque distribution, nettoie derrière lui, et suit les sorties de PHP. Publier -l'image ne remplace pas cet outil, elle en amortit le coût. +mlocati's `install-php-extensions` rather than a hand-written `pecl install`: it knows each +distribution's system packages, cleans up behind itself, and follows PHP releases. Publishing the +image does not replace that tool, it amortises its cost. -Deux vérifications, et elles ne disent pas la même chose. Le `Dockerfile` échoue si l'extension -n'est pas chargeable **dans la couche construite** ; le workflow relance ensuite `php -m` sur -**l'image publiée**. Une image peut être construite juste et poussée mal. +Two checks, and they do not say the same thing. The `Dockerfile` fails if the extension is not +loadable **in the built layer**; the workflow then runs `php -m` again on **the published image**. +An image can be built right and pushed wrong. -## Reconstruire +## Rebuilding -Quatre façons de le déclencher : +Four ways to trigger it: -| Déclencheur | Quand | +| Trigger | When | | --- | --- | -| `workflow_dispatch` | à la main, depuis l'onglet Actions | -| `repository_dispatch` | par webhook : `gh api repos/:owner/:repo/dispatches -f event_type=php-grpc-images` | -| `push` sur `main` | le `Dockerfile` ou le workflow ont changé | -| `schedule` | tous les lundis | - -La cadence hebdomadaire n'est pas du zèle. Deux choses vieillissent dans ces images, et pas au même -rythme : les paquets système de la base, et PHP lui-même, dont les versions correctives sortent -chaque mois. Reconstruire chaque semaine garde l'écart avec `php:8.4-cli` à quelques jours, et le -cache Buildx rend la reconstruction bon marché quand rien n'a bougé en amont. - -La publication n'a lieu que depuis `main`. Sur une pull request, le workflow construit sans pousser : -c'est la compilation qu'on veut vérifier, et une étiquette glissante ne doit pas désigner un essai -non relu. - -## Élaguer les étiquettes datées - -Seize images × une étiquette datée × chaque lundi, cela fait environ **huit cents étiquettes par -an**. Le stockage est gratuit sur un paquet public, mais la liste des versions devient illisible -bien avant de coûter quoi que ce soit. - -Le job `retention` s'exécute après chaque publication réussie et garde, **par série** -(`8.4-zts`, `8.2-cli-alpine`, …), les **huit étiquettes datées les plus récentes** en plus de -l'image courante — soit deux mois de points d'épinglage. Simulé sur soixante semaines : 144 versions -et 160 étiquettes au régime permanent, contre 960 et 976 sans rien faire. - -Ce que le script protège, et qui n'est pas évident : **une version est épargnée dès qu'elle porte -une étiquette non datée.** Le lundi de la publication, `8.4-zts` et `8.4-zts-20260828` désignent le -même manifeste ; « supprimer l'étiquette datée » retirerait l'image que l'étiquette glissante -désigne. La règle porte donc sur la version, jamais sur l'étiquette. Les versions **sans aucune -étiquette** sont épargnées aussi : ce ne sont pas des orphelines mais les attestations de provenance -de buildx, référencées par l'index qui, lui, est étiqueté. - -`retention.py --self-test` vérifie ces deux cas, et le job le lance avant d'appeler quoi que ce soit -qui supprime. - -> **Un secret est nécessaire.** L'API de suppression de paquets n'accepte pas le `GITHUB_TOKEN` : -> elle demande un jeton personnel *classique* portant `read:packages` et `delete:packages`, à -> déposer dans le secret `GHCR_RETENTION_TOKEN`. Sans lui, le job pose un avertissement et s'arrête -> sans échouer — un échec hebdomadaire finirait par être ignoré, et les vrais avec lui. - -Un essai à blanc est disponible : `workflow_dispatch` avec `dry_run` (activé par défaut) liste ce -qui serait retiré sans rien retirer. - -`arm64` est proposé en option et pas par défaut : il passe par QEMU, et émuler une compilation C++ -coûte bien plus que de la compiler. +| `workflow_dispatch` | by hand, from the Actions tab | +| `repository_dispatch` | by webhook: `gh api repos/:owner/:repo/dispatches -f event_type=php-grpc-images` | +| `push` on `main` | the `Dockerfile` or the workflow changed | +| `schedule` | every Monday | + +The weekly cadence is not zeal. Two things age in these images, and not at the same rate: the base's +system packages, and PHP itself, whose patch releases come out every month. Rebuilding every week +keeps the gap with `php:8.4-cli` down to a few days, and the Buildx cache makes the rebuild cheap +when nothing has moved upstream. + +Publication only happens from `main`. On a pull request the workflow builds without pushing: the +compilation is what we want to check, and a rolling tag must not designate an unreviewed experiment. + +## Pruning the dated tags + +Sixteen images × one dated tag × every Monday makes about **eight hundred tags a year**. Storage is +free on a public package, but the version list becomes unreadable well before it costs anything. + +The `retention` job runs after every successful publication and keeps, **per series** +(`8.4-zts`, `8.2-cli-alpine`, …), the **eight most recent dated tags** on top of the current +image — that is two months of pinning points. Simulated over sixty weeks: 144 versions and 160 tags +at steady state, against 960 and 976 with nothing done. + +What the script protects, and it is not obvious: **a version is spared as soon as it carries a tag +that is not dated.** On publication Monday, `8.4-zts` and `8.4-zts-20260828` designate the same +manifest; "deleting the dated tag" would remove the image the rolling tag designates. The rule +therefore bears on the version, never on the tag. Versions with **no tag at all** are spared too: +these are not orphans but buildx's provenance attestations, referenced by the index which, itself, +is tagged. + +`retention.py --self-test` checks these two cases, and the job runs it before calling anything that +deletes. + +> **A secret is required.** The package deletion API does not accept the `GITHUB_TOKEN`: it asks for +> a *classic* personal token carrying `read:packages` and `delete:packages`, to be placed in the +> `GHCR_RETENTION_TOKEN` secret. Without it, the job posts a warning and stops without failing — a +> weekly failure would end up ignored, and the real ones with it. + +A dry run is available: `workflow_dispatch` with `dry_run` (on by default) lists what would be +removed without removing anything. + +`arm64` is offered as an option and not by default: it goes through QEMU, and emulating a C++ +compilation costs far more than compiling it. diff --git a/docker/php-grpc/retention.py b/docker/php-grpc/retention.py index d54a5877..2ad021fa 100644 --- a/docker/php-grpc/retention.py +++ b/docker/php-grpc/retention.py @@ -1,23 +1,22 @@ #!/usr/bin/env python3 -"""Décide quelles versions du paquet `php-grpc` sur GHCR peuvent être supprimées. +"""Decides which versions of the `php-grpc` package on GHCR may be deleted. -Le workflow reconstruit seize images chaque lundi et pose, à chaque publication, une étiquette -glissante (`8.4-zts`) et une étiquette datée (`8.4-zts-20260828`). Les glissantes sont seize pour -toujours ; les datées, elles, s'accumulent — environ huit cents par an, et rien ne les élague. +The workflow rebuilds sixteen images every Monday and places, on each publication, a rolling tag +(`8.4-zts`) and a dated tag (`8.4-zts-20260828`). The rolling ones are sixteen forever; the dated +ones accumulate — about eight hundred a year, and nothing prunes them. -Ce script ne supprime rien : il lit la liste des versions et écrit les identifiants à supprimer. Ce -qui décide se relit en une phrase, et c'est voulu, parce que se tromper ici retire des images -publiées. +This script deletes nothing: it reads the version list and writes the identifiers to delete. What +decides reads back in a single sentence, and that is deliberate, because being wrong here removes +published images. -**Une version est protégée dès qu'elle porte une étiquette qui n'est pas datée.** Une version est un -manifeste, et un manifeste porte souvent plusieurs étiquettes : le lundi de la publication, -`8.4-zts` et `8.4-zts-20260828` désignent le même. Supprimer « l'étiquette datée » reviendrait à -supprimer l'image que `8.4-zts` désigne. La règle est donc formulée sur la version, jamais sur -l'étiquette. +**A version is protected as soon as it carries a tag that is not dated.** A version is a manifest, +and a manifest often carries several tags: on publication Monday, `8.4-zts` and `8.4-zts-20260828` +designate the same one. Deleting "the dated tag" would amount to deleting the image `8.4-zts` +designates. The rule is therefore stated on the version, never on the tag. -**Une version sans aucune étiquette est protégée aussi.** Elle n'est pas orpheline : buildx publie -les attestations de provenance en manifestes non étiquetés, référencés par l'index qui, lui, porte -l'étiquette. Les supprimer casse l'image publiée. +**A version with no tag at all is protected too.** It is not an orphan: buildx publishes provenance +attestations as untagged manifests, referenced by the index which does carry the tag. Deleting them +breaks the published image. """ from __future__ import annotations @@ -27,97 +26,97 @@ import re import sys -# `8.4-zts-alpine-20260828` : version mineure, forme, puis la date de publication. -DATEE = re.compile(r'^(?P\d+\.\d+-[a-z-]+)-(?P\d{8})$') +# `8.4-zts-alpine-20260828`: minor version, flavour, then the publication date. +DATED = re.compile(r'^(?P\d+\.\d+-[a-z-]+)-(?P\d{8})$') -def a_supprimer(versions: list[dict], garder: int) -> list[dict]: - """Les versions dont toutes les étiquettes sont datées, au-delà des `garder` plus récentes - de leur série. Renvoie des dicts `{id, tags, serie, date}`, les plus anciennes d'abord.""" +def to_delete(versions: list[dict], keep: int) -> list[dict]: + """The versions whose tags are all dated, beyond the `keep` most recent of their series. + Returns dicts `{id, tags, series, date}`, oldest first.""" candidates: dict[str, list[dict]] = {} for version in versions: - etiquettes = version.get('tags') or [] - if not etiquettes: - continue # attestations buildx — voir le docstring - correspondances = [DATEE.match(e) for e in etiquettes] - if not all(correspondances): - continue # au moins une étiquette glissante : la version est en service - # Une version peut porter plusieurs dates si deux publications ont donné le même manifeste. - # C'est la plus récente qui décide de son rang. - recente = max(correspondances, key=lambda m: m.group('date')) - candidates.setdefault(recente.group('serie'), []).append({ + tags_of = version.get('tags') or [] + if not tags_of: + continue # buildx attestations — see the docstring + matches = [DATED.match(e) for e in tags_of] + if not all(matches): + continue # at least one rolling tag: the version is in service + # A version can carry several dates if two publications produced the same manifest. + # It is the most recent one that decides its rank. + most_recent = max(matches, key=lambda m: m.group('date')) + candidates.setdefault(most_recent.group('series'), []).append({ 'id': version['id'], - 'tags': etiquettes, - 'serie': recente.group('serie'), - 'date': recente.group('date'), + 'tags': tags_of, + 'series': most_recent.group('series'), + 'date': most_recent.group('date'), }) - surnuméraires = [] - for serie in candidates.values(): - serie.sort(key=lambda v: v['date'], reverse=True) - surnuméraires.extend(serie[garder:]) - surnuméraires.sort(key=lambda v: (v['serie'], v['date'])) - return surnuméraires + surplus = [] + for series in candidates.values(): + series.sort(key=lambda v: v['date'], reverse=True) + surplus.extend(series[keep:]) + surplus.sort(key=lambda v: (v['series'], v['date'])) + return surplus -def _autotest() -> None: - protegee_car_glissante = {'id': 1, 'tags': ['8.4-zts', '8.4-zts-20260828']} - datee_seule = {'id': 2, 'tags': ['8.4-zts-20260821']} - plus_ancienne = {'id': 3, 'tags': ['8.4-zts-20260814']} - sans_etiquette = {'id': 4, 'tags': []} - autre_serie = {'id': 5, 'tags': ['8.2-cli-alpine-20260814']} +def _self_test() -> None: + protected_by_rolling = {'id': 1, 'tags': ['8.4-zts', '8.4-zts-20260828']} + dated_only = {'id': 2, 'tags': ['8.4-zts-20260821']} + older = {'id': 3, 'tags': ['8.4-zts-20260814']} + untagged = {'id': 4, 'tags': []} + other_series = {'id': 5, 'tags': ['8.2-cli-alpine-20260814']} - toutes = [protegee_car_glissante, datee_seule, plus_ancienne, sans_etiquette, autre_serie] + all_of_them = [protected_by_rolling, dated_only, older, untagged, other_series] - # Le cas qui compte : la version que `8.4-zts` désigne n'est jamais candidate, même si elle - # porte aussi la plus vieille des étiquettes datées. - assert [v['id'] for v in a_supprimer(toutes, garder=99)] == [] - assert [v['id'] for v in a_supprimer(toutes, garder=1)] == [3] - assert [v['id'] for v in a_supprimer(toutes, garder=0)] == [5, 3, 2] + # The case that matters: the version `8.4-zts` designates is never a candidate, even when it + # also carries the oldest of the dated tags. + assert [v['id'] for v in to_delete(all_of_them, keep=99)] == [] + assert [v['id'] for v in to_delete(all_of_them, keep=1)] == [3] + assert [v['id'] for v in to_delete(all_of_them, keep=0)] == [5, 3, 2] - # `garder` compte par série, pas globalement : deux séries de deux gardent une chacune. - deux_series = [ + # `keep` counts per series, not globally: two series of two keep one each. + two_series = [ {'id': 10, 'tags': ['8.4-zts-20260828']}, {'id': 11, 'tags': ['8.4-zts-20260821']}, {'id': 12, 'tags': ['8.2-cli-20260828']}, {'id': 13, 'tags': ['8.2-cli-20260821']}, ] - assert sorted(v['id'] for v in a_supprimer(deux_series, garder=1)) == [11, 13] + assert sorted(v['id'] for v in to_delete(two_series, keep=1)) == [11, 13] - # Une version qui porte deux dates est classée sur la plus récente. - deux_dates = [ + # A version that carries two dates is ranked on the most recent one. + two_dates = [ {'id': 20, 'tags': ['8.4-zts-20260828', '8.4-zts-20260821']}, {'id': 21, 'tags': ['8.4-zts-20260814']}, ] - assert [v['id'] for v in a_supprimer(deux_dates, garder=1)] == [21] + assert [v['id'] for v in to_delete(two_dates, keep=1)] == [21] - # Une étiquette inattendue protège : mieux vaut garder une image de trop qu'en retirer une - # que quelqu'un utilise. - inattendue = [{'id': 30, 'tags': ['latest']}, {'id': 31, 'tags': ['8.4-zts-experimental']}] - assert a_supprimer(inattendue, garder=0) == [] + # An unexpected tag protects: better to keep one image too many than remove one that + # somebody uses. + unexpected = [{'id': 30, 'tags': ['latest']}, {'id': 31, 'tags': ['8.4-zts-experimental']}] + assert to_delete(unexpected, keep=0) == [] - print('autotest : ok') + print('self-test: ok') def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--keep', type=int, default=8, - help='étiquettes datées conservées par série (défaut : 8, soit deux mois)') + help='dated tags kept per series (default: 8, i.e. two months)') parser.add_argument('--self-test', action='store_true') args = parser.parse_args() if args.self_test: - _autotest() + _self_test() return 0 versions = json.load(sys.stdin) - surnuméraires = a_supprimer(versions, args.keep) + surplus = to_delete(versions, args.keep) - print(f'{len(versions)} version(s) publiée(s), {len(surnuméraires)} à retirer ' - f'(on garde les {args.keep} plus récentes de chaque série)', file=sys.stderr) - for v in surnuméraires: + print(f'{len(versions)} version(s) published, {len(surplus)} to remove ' + f'(keeping the {args.keep} most recent of each series)', file=sys.stderr) + for v in surplus: print(f" {v['id']} {', '.join(v['tags'])}", file=sys.stderr) - for v in surnuméraires: + for v in surplus: print(v['id']) return 0