From 9f24ce0e3f56dc693841029c08862d983585de7f Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Fri, 6 Nov 2026 07:25:50 +0100 Subject: [PATCH 1/5] ci: add WordPress.org deploy and PHP format workflows --- .distignore | 25 +++++++ .github/dependabot.yml | 36 ++++++++++ .github/workflows/dependabot-automerge.yml | 7 ++ .github/workflows/format-php.yml | 51 ++++++++++++++ .github/workflows/wordpress-org-deploy.yml | 81 ++++++++++++++++++++++ 5 files changed, 200 insertions(+) create mode 100644 .distignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-automerge.yml create mode 100644 .github/workflows/format-php.yml create mode 100644 .github/workflows/wordpress-org-deploy.yml diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..3f53f17 --- /dev/null +++ b/.distignore @@ -0,0 +1,25 @@ +# Files and folders excluded from the WordPress.org release (used by 10up/action-wordpress-plugin-deploy). +/.git +/.github +/.wordpress-org +/node_modules +/tests +/vendor +/assets + +.distignore +.editorconfig +.gitignore +.gitattributes +.DS_Store +.phpunit.result.cache +composer.json +composer.lock +package.json +package-lock.json +phpcs.xml +phpunit.xml +webpack.config.js +README.md +*.map +*.zip diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d49702b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "(chore)" + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + npm: + patterns: + - "*" + commit-message: + prefix: "(chore)" + versioning-strategy: lockfile-only + + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + groups: + composer: + patterns: + - "*" + commit-message: + prefix: "(chore)" + versioning-strategy: lockfile-only diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..285fa71 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,7 @@ +name: Dependabot Automerge +on: pull_request + +jobs: + dependabot-automerge: + uses: yardinternet/workflows/.github/workflows/dependabot-automerge.yml@main + secrets: inherit diff --git a/.github/workflows/format-php.yml b/.github/workflows/format-php.yml new file mode 100644 index 0000000..18846db --- /dev/null +++ b/.github/workflows/format-php.yml @@ -0,0 +1,51 @@ +name: Format PHP + +on: + push: + branches: + - main + paths: + - '**.php' + - 'phpcs.xml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + phpcbf: + name: phpcbf + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer + + # --no-scripts: the post-install scripts remove vendor/, which holds phpcbf. + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-scripts + + # phpcbf exits 1 when it fixed everything and 2 when unfixable violations remain; + # both are fine here, only 3 (processing error) should fail the job. + - name: Run phpcbf + run: composer run phpcbf || [ $? -le 2 ] + + # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so this cannot loop. + - name: Commit formatting changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: 'style: apply phpcbf changes' + file_pattern: '*.php' + # Pass the pattern to git unexpanded so it matches PHP files in subdirectories too. + disable_globbing: true diff --git a/.github/workflows/wordpress-org-deploy.yml b/.github/workflows/wordpress-org-deploy.yml new file mode 100644 index 0000000..2ab4186 --- /dev/null +++ b/.github/workflows/wordpress-org-deploy.yml @@ -0,0 +1,81 @@ +name: Deploy to WordPress.org + +on: + push: + tags: + - "v*" + - "[0-9]*" + workflow_dispatch: + inputs: + dry-run: + description: 'Dry run: build and package, but do not commit to WordPress.org SVN' + type: boolean + default: true + +jobs: + deploy: + name: Deploy new tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Check the version numbers + run: | + PLUGIN_VERSION=$(grep -m1 -E '^\s*\*\s*Version:' yard-deepl.php | awk '{print $NF}' | tr -d '\r') + STABLE_TAG=$(grep -m1 -E '^Stable tag:' readme.txt | awk '{print $NF}' | tr -d '\r') + README_STABLE_TAG=$(grep -m1 -E '^Stable tag:' README.md | awk '{print $NF}' | tr -d '\r') + echo "Plugin header: $PLUGIN_VERSION, readme.txt stable tag: $STABLE_TAG, README.md stable tag: $README_STABLE_TAG, ref: $GITHUB_REF_NAME" + if [ "$PLUGIN_VERSION" != "$STABLE_TAG" ]; then + echo "::error::Plugin header Version and readme.txt Stable tag must be equal." + exit 1 + fi + if [ "$PLUGIN_VERSION" != "$README_STABLE_TAG" ]; then + echo "::error::Plugin header Version and README.md Stable tag must be equal." + exit 1 + fi + if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "${GITHUB_REF_NAME#v}" != "$PLUGIN_VERSION" ]; then + echo "::error::Tag must match the plugin header Version." + exit 1 + fi + if [ "$GITHUB_REF_TYPE" != "tag" ] && [ "${{ inputs.dry-run }}" != "true" ]; then + echo "::error::A real deploy must run from a tag. Pick a tag under 'Use workflow from', or enable dry run." + exit 1 + fi + # Used by the deploy action as the SVN tag name. + echo "VERSION=$PLUGIN_VERSION" >> "$GITHUB_ENV" + + # No Composer step: vendor-prefixed/ is committed (force-added despite .gitignore) and + # shipped as-is, and the plugin never loads vendor/ (excluded via .distignore). + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Build assets + run: | + npm ci + npm run build + + - name: WordPress plugin deploy + id: deploy + uses: 10up/action-wordpress-plugin-deploy@stable + with: + generate-zip: true + dry-run: ${{ inputs.dry-run == true }} + env: + SLUG: yard-deepl + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + + - name: Create GitHub release + if: github.ref_type == 'tag' && inputs.dry-run != true + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.deploy.outputs.zip-path }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From b10b2392b5f32d0e7121d0284dc7f487947bd732 Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Tue, 10 Nov 2026 08:25:33 +0100 Subject: [PATCH 2/5] fix: track vendor-prefixed and add missing PHP-DI files --- .gitignore | 1 - .../php-di/php-di/src/Attribute/Inject.php | 79 ++++++ .../php-di/src/Attribute/Injectable.php | 39 +++ .../Definition/Exception/InvalidAttribute.php | 19 ++ .../Source/AttributeBasedAutowiring.php | 268 ++++++++++++++++++ 5 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 vendor-prefixed/php-di/php-di/src/Attribute/Inject.php create mode 100644 vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php create mode 100644 vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php create mode 100644 vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php diff --git a/.gitignore b/.gitignore index 5676717..c3b5170 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ node_modules tests/coverage vendor -vendor-prefixed diff --git a/vendor-prefixed/php-di/php-di/src/Attribute/Inject.php b/vendor-prefixed/php-di/php-di/src/Attribute/Inject.php new file mode 100644 index 0000000..42e80df --- /dev/null +++ b/vendor-prefixed/php-di/php-di/src/Attribute/Inject.php @@ -0,0 +1,79 @@ + + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] +final class Inject +{ + /** + * Entry name. + */ + private ?string $name = null; + + /** + * Parameters, indexed by the parameter number (index) or name. + * + * Used if the attribute is set on a method + */ + private array $parameters = []; + + /** + * @throws InvalidAttribute + */ + public function __construct(string|array|null $name = null) + { + // #[Inject('foo')] or #[Inject(name: 'foo')] + if (is_string($name)) { + $this->name = $name; + } + + // #[Inject([...])] on a method + if (is_array($name)) { + foreach ($name as $key => $value) { + if (! is_string($value)) { + throw new InvalidAttribute(sprintf( + "#[Inject(['param' => 'value'])] expects \"value\" to be a string, %s given.", + json_encode($value, \JSON_THROW_ON_ERROR) + )); + } + + $this->parameters[$key] = $value; + } + } + } + + /** + * @return string|null Name of the entry to inject + */ + public function getName() : string|null + { + return $this->name; + } + + /** + * @return array Parameters, indexed by the parameter number (index) or name + */ + public function getParameters() : array + { + return $this->parameters; + } +} diff --git a/vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php b/vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php new file mode 100644 index 0000000..840954d --- /dev/null +++ b/vendor-prefixed/php-di/php-di/src/Attribute/Injectable.php @@ -0,0 +1,39 @@ + + * @author Matthieu Napoli + */ +#[Attribute(Attribute::TARGET_CLASS)] +final class Injectable +{ + /** + * @param bool|null $lazy Should the object be lazy-loaded. + */ + public function __construct( + private ?bool $lazy = null, + ) { + } + + public function isLazy() : bool|null + { + return $this->lazy; + } +} diff --git a/vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php b/vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php new file mode 100644 index 0000000..07d2743 --- /dev/null +++ b/vendor-prefixed/php-di/php-di/src/Definition/Exception/InvalidAttribute.php @@ -0,0 +1,19 @@ + + */ +class InvalidAttribute extends InvalidDefinition +{ +} diff --git a/vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php b/vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php new file mode 100644 index 0000000..0d9ce1d --- /dev/null +++ b/vendor-prefixed/php-di/php-di/src/Definition/Source/AttributeBasedAutowiring.php @@ -0,0 +1,268 @@ + + */ +class AttributeBasedAutowiring implements DefinitionSource, Autowiring +{ + /** + * @throws InvalidAttribute + */ + public function autowire(string $name, ?ObjectDefinition $definition = null) : ObjectDefinition|null + { + $className = $definition ? $definition->getClassName() : $name; + + if (!class_exists($className) && !interface_exists($className)) { + return $definition; + } + + $definition = $definition ?: new ObjectDefinition($name); + + $class = new ReflectionClass($className); + + $this->readInjectableAttribute($class, $definition); + + // Browse the class properties looking for annotated properties + $this->readProperties($class, $definition); + + // Browse the object's methods looking for annotated methods + $this->readMethods($class, $definition); + + return $definition; + } + + /** + * @throws InvalidAttribute + * @throws InvalidArgumentException The class doesn't exist + */ + public function getDefinition(string $name) : ObjectDefinition|null + { + return $this->autowire($name); + } + + /** + * Autowiring cannot guess all existing definitions. + */ + public function getDefinitions() : array + { + return []; + } + + /** + * Browse the class properties looking for annotated properties. + */ + private function readProperties(ReflectionClass $class, ObjectDefinition $definition) : void + { + foreach ($class->getProperties() as $property) { + $this->readProperty($property, $definition); + } + + // Read also the *private* properties of the parent classes + /** @noinspection PhpAssignmentInConditionInspection */ + while ($class = $class->getParentClass()) { + foreach ($class->getProperties(ReflectionProperty::IS_PRIVATE) as $property) { + $this->readProperty($property, $definition, $class->getName()); + } + } + } + + /** + * @throws InvalidAttribute + */ + private function readProperty(ReflectionProperty $property, ObjectDefinition $definition, ?string $classname = null) : void + { + if ($property->isStatic() || $property->isPromoted()) { + return; + } + + // Look for #[Inject] attribute + try { + $attribute = $property->getAttributes(Inject::class)[0] ?? null; + if (! $attribute) { + return; + } + /** @var Inject $inject */ + $inject = $attribute->newInstance(); + } catch (Throwable $e) { + throw new InvalidAttribute(sprintf( + '#[Inject] annotation on property %s::%s is malformed. %s', + $property->getDeclaringClass()->getName(), + $property->getName(), + $e->getMessage() + ), 0, $e); + } + + // Try to #[Inject("name")] or look for the property type + $entryName = $inject->getName(); + + // Try using typed properties + $propertyType = $property->getType(); + if ($entryName === null && $propertyType instanceof ReflectionNamedType) { + if (! class_exists($propertyType->getName()) && ! interface_exists($propertyType->getName())) { + throw new InvalidAttribute(sprintf( + '#[Inject] found on property %s::%s but unable to guess what to inject, the type of the property does not look like a valid class or interface name', + $property->getDeclaringClass()->getName(), + $property->getName() + )); + } + $entryName = $propertyType->getName(); + } + + if ($entryName === null) { + throw new InvalidAttribute(sprintf( + '#[Inject] found on property %s::%s but unable to guess what to inject, please add a type to the property', + $property->getDeclaringClass()->getName(), + $property->getName() + )); + } + + $definition->addPropertyInjection( + new PropertyInjection($property->getName(), new Reference($entryName), $classname) + ); + } + + /** + * Browse the object's methods looking for annotated methods. + */ + private function readMethods(ReflectionClass $class, ObjectDefinition $objectDefinition) : void + { + // This will look in all the methods, including those of the parent classes + foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic()) { + continue; + } + + $methodInjection = $this->getMethodInjection($method); + + if (! $methodInjection) { + continue; + } + + if ($method->isConstructor()) { + $objectDefinition->completeConstructorInjection($methodInjection); + } else { + $objectDefinition->completeFirstMethodInjection($methodInjection); + } + } + } + + private function getMethodInjection(ReflectionMethod $method) : ?MethodInjection + { + // Look for #[Inject] attribute + $attribute = $method->getAttributes(Inject::class)[0] ?? null; + + if ($attribute) { + /** @var Inject $inject */ + $inject = $attribute->newInstance(); + $annotationParameters = $inject->getParameters(); + } elseif ($method->isConstructor()) { + // #[Inject] on constructor is implicit, we continue + $annotationParameters = []; + } else { + return null; + } + + $parameters = []; + foreach ($method->getParameters() as $index => $parameter) { + $entryName = $this->getMethodParameter($index, $parameter, $annotationParameters); + + if ($entryName !== null) { + $parameters[$index] = new Reference($entryName); + } + } + + if ($method->isConstructor()) { + return MethodInjection::constructor($parameters); + } + + return new MethodInjection($method->getName(), $parameters); + } + + /** + * @return string|null Entry name or null if not found. + */ + private function getMethodParameter(int $parameterIndex, ReflectionParameter $parameter, array $annotationParameters) : ?string + { + // Let's check if this parameter has an #[Inject] attribute + $attribute = $parameter->getAttributes(Inject::class)[0] ?? null; + if ($attribute) { + /** @var Inject $inject */ + $inject = $attribute->newInstance(); + + return $inject->getName(); + } + + // #[Inject] has definition for this parameter (by index, or by name) + if (isset($annotationParameters[$parameterIndex])) { + return $annotationParameters[$parameterIndex]; + } + if (isset($annotationParameters[$parameter->getName()])) { + return $annotationParameters[$parameter->getName()]; + } + + // Skip optional parameters if not explicitly defined + if ($parameter->isOptional()) { + return null; + } + + // Look for the property type + $parameterType = $parameter->getType(); + if ($parameterType instanceof ReflectionNamedType && !$parameterType->isBuiltin()) { + return $parameterType->getName(); + } + + return null; + } + + /** + * @throws InvalidAttribute + */ + private function readInjectableAttribute(ReflectionClass $class, ObjectDefinition $definition) : void + { + try { + $attribute = $class->getAttributes(Injectable::class)[0] ?? null; + if (! $attribute) { + return; + } + $attribute = $attribute->newInstance(); + } catch (Throwable $e) { + throw new InvalidAttribute(sprintf( + 'Error while reading #[Injectable] on %s: %s', + $class->getName(), + $e->getMessage() + ), 0, $e); + } + + if ($attribute->isLazy() !== null) { + $definition->setLazy($attribute->isLazy()); + } + } +} From b4ee71fc36ae2da50bd723131874bc2cbf078314 Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Tue, 10 Nov 2026 09:50:56 +0100 Subject: [PATCH 3/5] ci: verify vendor-prefixed on deploy and rebuild it on Dependabot PRs --- .distignore | 1 + .github/workflows/dependabot-automerge.yml | 2 + .../workflows/dependabot-vendor-prefixed.yml | 71 +++++++++++ .github/workflows/wordpress-org-deploy.yml | 44 ++++++- README.md | 4 +- docs/RELEASING.md | 111 ++++++++++++++++++ 6 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/dependabot-vendor-prefixed.yml create mode 100644 docs/RELEASING.md diff --git a/.distignore b/.distignore index 3f53f17..b077088 100644 --- a/.distignore +++ b/.distignore @@ -6,6 +6,7 @@ /tests /vendor /assets +/docs .distignore .editorconfig diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 285fa71..a95ecbc 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -3,5 +3,7 @@ on: pull_request jobs: dependabot-automerge: + # Composer PRs are merged by dependabot-vendor-prefixed.yml after vendor-prefixed/ is rebuilt. + if: ${{ !startsWith(github.head_ref, 'dependabot/composer/') }} uses: yardinternet/workflows/.github/workflows/dependabot-automerge.yml@main secrets: inherit diff --git a/.github/workflows/dependabot-vendor-prefixed.yml b/.github/workflows/dependabot-vendor-prefixed.yml new file mode 100644 index 0000000..46b1eea --- /dev/null +++ b/.github/workflows/dependabot-vendor-prefixed.yml @@ -0,0 +1,71 @@ +name: Dependabot vendor-prefixed + +# Dependabot Composer PRs only change composer.lock. This rebuilds vendor-prefixed/ from the +# updated lock file, pushes it to the PR branch and then merges the PR, so the shipped prefixed +# code always matches composer.lock (the deploy workflow verifies this). +on: pull_request + +# The reusable automerge workflow uses `${{ github.workflow }}-${{ github.ref }}` as its group, +# and github.workflow keeps the caller's name there. The suffix keeps the groups apart, otherwise +# the automerge job would cancel this run. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-rebuild + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + rebuild: + name: Rebuild vendor-prefixed + if: github.event.pull_request.user.login == 'dependabot[bot]' && startsWith(github.head_ref, 'dependabot/composer/') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out PR branch + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref }} + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer + + # The post-install scripts run Strauss and then remove vendor/. + - name: Rebuild vendor-prefixed + run: | + rm -rf vendor-prefixed + composer install --no-interaction --prefer-dist + if [ ! -f vendor-prefixed/autoload.php ]; then + echo "::error::vendor-prefixed/autoload.php was not generated." + exit 1 + fi + + # Strauss stamps every file with the build date. Revert files where that is the only change, + # so the commit only contains real dependency changes. + - name: Drop date-only changes + run: | + git diff --name-only -- vendor-prefixed | while read -r file; do + if git diff -I 'Modified by .* using' --quiet -- "$file"; then + git checkout -- "$file" + fi + done + + # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so this cannot loop. + - name: Commit vendor-prefixed changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: '(chore): update vendor prefixed directory' + file_pattern: 'vendor-prefixed' + + # Composer PRs are skipped by the general automerge workflow, so they are merged here, + # only after vendor-prefixed/ has been pushed. + automerge: + name: Automerge + needs: rebuild + uses: yardinternet/workflows/.github/workflows/dependabot-automerge.yml@main + secrets: inherit diff --git a/.github/workflows/wordpress-org-deploy.yml b/.github/workflows/wordpress-org-deploy.yml index 2ab4186..8ec10f9 100644 --- a/.github/workflows/wordpress-org-deploy.yml +++ b/.github/workflows/wordpress-org-deploy.yml @@ -47,8 +47,34 @@ jobs: # Used by the deploy action as the SVN tag name. echo "VERSION=$PLUGIN_VERSION" >> "$GITHUB_ENV" - # No Composer step: vendor-prefixed/ is committed (force-added despite .gitignore) and - # shipped as-is, and the plugin never loads vendor/ (excluded via .distignore). + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer + + # vendor-prefixed/ is committed and shipped as-is. Regenerate it from composer.lock and fail + # when it differs from the committed copy, so a Composer update (e.g. from Dependabot) + # can never ship without the matching prefixed code being committed first. + # The post-install scripts run Strauss and then remove vendor/, which the plugin never loads. + - name: Verify prefixed dependencies match composer.lock + run: | + COMMITTED="$RUNNER_TEMP/vendor-prefixed-committed" + mv vendor-prefixed "$COMMITTED" + composer install --no-interaction --prefer-dist + if [ ! -f vendor-prefixed/autoload.php ]; then + echo "::error::vendor-prefixed/autoload.php was not generated." + exit 1 + fi + # Strauss stamps every file with the build date; ignore those lines. + if ! diff -r -I 'Modified by .* using' "$COMMITTED" vendor-prefixed; then + echo "::error::vendor-prefixed/ is out of date with composer.lock. Run 'composer install' locally and commit vendor-prefixed/." + exit 1 + fi + # Ship exactly the committed files. + rm -rf vendor-prefixed + mv "$COMMITTED" vendor-prefixed - name: Set up Node uses: actions/setup-node@v4 @@ -65,17 +91,27 @@ jobs: id: deploy uses: 10up/action-wordpress-plugin-deploy@stable with: - generate-zip: true dry-run: ${{ inputs.dry-run == true }} env: SLUG: yard-deepl SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + # Build the zip from this checkout instead of the action's generate-zip: when the SVN tag + # already exists (rerun), the action zips SVN trunk, which may hold a newer version. + - name: Build release zip + id: zip + run: | + BUILD_DIR="$RUNNER_TEMP/build" + mkdir -p "$BUILD_DIR/yard-deepl" + rsync -rc --exclude-from=.distignore ./ "$BUILD_DIR/yard-deepl/" + (cd "$BUILD_DIR" && zip -rq yard-deepl.zip yard-deepl) + echo "zip-path=$BUILD_DIR/yard-deepl.zip" >> "$GITHUB_OUTPUT" + - name: Create GitHub release if: github.ref_type == 'tag' && inputs.dry-run != true uses: softprops/action-gh-release@v2 with: - files: ${{ steps.deploy.outputs.zip-path }} + files: ${{ steps.zip.outputs.zip-path }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 6a7c4b3..8bd6bc9 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ If you need to work with development dependencies, follow these steps: 1. Run `composer install`. This will remove the `vendor` directory and create the `vendor-prefixed` directory. 2. Run `composer install --no-scripts`. The `--no-scripts` flag prevents the automatic deletion of the `vendor` directory, allowing you to work with the development dependencies. Ensure that the `vendor-prefixed` directory is still present, as the plug-in relies on it. +`vendor-prefixed` is committed to git. After changing Composer packages, commit `vendor-prefixed` together with `composer.lock`; the deploy stops when they don't match. + ### Ready for releasing a new version? -1. Run `composer install --optimize-autoloader`. This will optimize the autoloading rules by converting them into a class map, which improves performance and speeds up autoloading. It is particularly useful for production environments. +See [docs/RELEASING.md](docs/RELEASING.md) for the release routine and how the GitHub workflows fit together. ## Usage diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..b69cc93 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,111 @@ +# Releasing + +How changes reach `main`, and how a tag on `main` becomes a release on WordPress.org and GitHub. All workflows live in [`.github/workflows`](../.github/workflows). + +## The whole picture + +```mermaid +flowchart TD + subgraph composer["Dependabot: Composer"] + C1["Weekly PR opened
(only composer.lock changed)"] --> C2["composer-lock-diff
comments which packages changed"] + C2 --> C3["dependabot-vendor-prefixed
rebuilds vendor-prefixed/, pushes it, merges"] + end + + subgraph other["Dependabot: Actions, npm"] + D1["Weekly PR opened"] --> D2["dependabot-automerge
approves and merges right away"] + end + + subgraph you["You: code changes"] + Y1["Change code
(+ commit vendor-prefixed/ after Composer changes)"] --> Y2["Commit and push,
or merge a pull request"] + end + + C3 --> MAIN[("main")] + D2 --> MAIN + Y2 --> MAIN + MAIN <-->|"push with .php changes /
style: apply phpcbf changes"| F["format-php
runs phpcbf, commits fixes"] + + MAIN --> R1["Bump the version
yard-deepl.php, readme.txt, README.md"] + + subgraph release["Release: you start it"] + R1 --> R2["Push a tag, e.g. v2.3.0"] + R2 --> R3["wordpress-org-deploy
checks, builds, publishes"] + R3 --> O1["WordPress.org
new version in the plugin directory"] + R3 --> O2["GitHub release
zip built from the tag"] + end +``` + +Anything can land on `main` at any time. Nothing is published until you push a tag. + +## Release routine + +1. Make sure `main` has everything you want in the release. +2. Set the new version in: + - `yard-deepl.php`: `Version:` + - `readme.txt`: `Stable tag:`, plus a changelog entry + - `README.md`: `Stable tag:`, plus a changelog entry +3. Commit (for example `release: v2.3.0`) and push. +4. Optional: run **Deploy to WordPress.org** manually under the Actions tab. Dry run is on by default, so it runs every check and build without publishing. +5. Push the tag: + + ```sh + git tag v2.3.0 + git push origin v2.3.0 + ``` + +6. Follow the run under the Actions tab. When it is green, the version is live on WordPress.org and GitHub. + +## The workflows + +| Workflow | Runs when | What it does | +| --- | --- | --- | +| `composer-lock-diff.yml` | A PR changes `composer.lock` | Comments on the PR which packages changed, from which version to which. | +| `dependabot-vendor-prefixed.yml` | Dependabot opens a Composer PR (`dependabot/composer/...`) | Rebuilds `vendor-prefixed/` from the new `composer.lock`, drops date-only changes, pushes the result to the PR and merges it. | +| `dependabot-automerge.yml` | Dependabot opens any other PR (Actions, npm) | Approves and merges the PR right away. Composer PRs are skipped; `dependabot-vendor-prefixed.yml` merges those after the rebuild. | +| `format-php.yml` | A push to `main` changes a `.php` file or `phpcs.xml` | Runs `phpcbf` and commits the fixes as `style: apply phpcbf changes`. | +| `wordpress-org-deploy.yml` | A tag is pushed, or a manual run | Checks, builds and publishes the release. See below. | + +### Inside wordpress-org-deploy + +The steps run in order. If a check fails, the run stops and nothing is published. + +1. **Check out the tag.** The code exactly as it was when tagged, even if `main` has moved on. +2. **Check the version numbers.** The version in `yard-deepl.php`, the stable tag in `readme.txt` and `README.md`, and the tag name must all be the same. *Stops if they differ.* +3. **Check `vendor-prefixed/`.** Rebuilds it from `composer.lock` and compares it with the committed copy, ignoring the Strauss date lines. Then puts the committed copy back, so the release ships exactly what is in git. *Stops if out of date.* +4. **Build assets.** `npm ci` and `npm run build` produce `dist/`. +5. **Upload to WordPress.org.** The 10up action copies the plugin to SVN, without the files in `.distignore`. *Skipped in a dry run.* +6. **Build the zip** from the same checked-out files, without the files in `.distignore`. Because it uses the tag, a rerun always gives the zip for that version. +7. **Create the GitHub release** for the tag and attach the zip. *Skipped in a dry run.* + +## vendor-prefixed/ + +The plugin loads its Composer dependencies (PHP-DI and its packages) from `vendor-prefixed/`, never from `vendor/`. [Strauss](https://github.com/BrianHenryIE/strauss) builds it from `composer.lock` during `composer install`, and it is committed to git. + +- After changing Composer packages yourself, run `composer install` and commit `vendor-prefixed/` together with `composer.lock`. +- Strauss writes the build date into every file (`Modified by ... on `), so every `composer install` touches all files. Undo only the files where the date is the only change, so real dependency changes stay: + + ```sh + git diff --name-only -- vendor-prefixed | while read -r file; do + git diff -I 'Modified by .* using' --quiet -- "$file" && git checkout -- "$file" + done + ``` + + Don't use `git restore vendor-prefixed`: that also throws away the real changes from a dependency update. +- The deploy checks that the committed `vendor-prefixed/` matches `composer.lock` and stops if it doesn't. + +## When something stops + +| What happened | Where | How to fix it | +| --- | --- | --- | +| Version numbers differ | `wordpress-org-deploy`, step 2 | Make the version the same in the three files and the tag. Delete the wrong tag, commit the fix and tag again. | +| `vendor-prefixed/` is out of date | `wordpress-org-deploy`, step 3 | Run `composer install` locally, commit `vendor-prefixed/`, then tag again. | +| A Dependabot Composer PR stays open | `dependabot-vendor-prefixed` | The rebuild or merge failed. Open the failed run to see why. Comment `@dependabot recreate` on the PR to start over. | +| A `style: apply phpcbf changes` commit appears | `format-php` | Nothing to fix. Pull before you continue working, so you have the formatted code. | + +To delete a wrong tag before tagging again: + +```sh +git tag -d v2.3.0 +git push origin :refs/tags/v2.3.0 +``` + +Only do this when the run stopped before step 5. Once a version is on WordPress.org, release a new version instead of reusing the tag. From 9e33064de5cbb4c153388f574a4e9eae9342f84e Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Fri, 13 Nov 2026 07:13:49 +0100 Subject: [PATCH 4/5] ci: serialize WordPress.org deploys and ignore classmap order in vendor-prefixed check --- .github/workflows/dependabot-vendor-prefixed.yml | 8 +++++++- .github/workflows/wordpress-org-deploy.yml | 10 +++++++++- docs/RELEASING.md | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependabot-vendor-prefixed.yml b/.github/workflows/dependabot-vendor-prefixed.yml index 46b1eea..9371470 100644 --- a/.github/workflows/dependabot-vendor-prefixed.yml +++ b/.github/workflows/dependabot-vendor-prefixed.yml @@ -46,7 +46,8 @@ jobs: fi # Strauss stamps every file with the build date. Revert files where that is the only change, - # so the commit only contains real dependency changes. + # so the commit only contains real dependency changes. Strauss also writes the classmap + # entries in no fixed order, so revert it when only the order changed. - name: Drop date-only changes run: | git diff --name-only -- vendor-prefixed | while read -r file; do @@ -54,6 +55,11 @@ jobs: git checkout -- "$file" fi done + CLASSMAP=vendor-prefixed/autoload-classmap.php + if git ls-files --error-unmatch "$CLASSMAP" >/dev/null 2>&1 \ + && diff -q <(git show "HEAD:$CLASSMAP" | sort) <(sort "$CLASSMAP") >/dev/null; then + git checkout -- "$CLASSMAP" + fi # Pushes made with GITHUB_TOKEN do not trigger new workflow runs, so this cannot loop. - name: Commit vendor-prefixed changes diff --git a/.github/workflows/wordpress-org-deploy.yml b/.github/workflows/wordpress-org-deploy.yml index 8ec10f9..c4164ea 100644 --- a/.github/workflows/wordpress-org-deploy.yml +++ b/.github/workflows/wordpress-org-deploy.yml @@ -12,6 +12,12 @@ on: type: boolean default: true +# One deploy at a time, whatever the ref: a tag push and a manual dispatch for the same version +# could otherwise both pass the SVN tag check and collide. Never cancel a running publish. +concurrency: + group: wordpress-org-deploy + cancel-in-progress: false + jobs: deploy: name: Deploy new tag @@ -68,7 +74,9 @@ jobs: exit 1 fi # Strauss stamps every file with the build date; ignore those lines. - if ! diff -r -I 'Modified by .* using' "$COMMITTED" vendor-prefixed; then + # Strauss writes the classmap entries in no fixed order, so that file is compared sorted below. + if ! diff -r -I 'Modified by .* using' -x autoload-classmap.php "$COMMITTED" vendor-prefixed \ + || ! diff <(sort "$COMMITTED/autoload-classmap.php") <(sort vendor-prefixed/autoload-classmap.php); then echo "::error::vendor-prefixed/ is out of date with composer.lock. Run 'composer install' locally and commit vendor-prefixed/." exit 1 fi diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b69cc93..d092295 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -70,7 +70,7 @@ The steps run in order. If a check fails, the run stops and nothing is published 1. **Check out the tag.** The code exactly as it was when tagged, even if `main` has moved on. 2. **Check the version numbers.** The version in `yard-deepl.php`, the stable tag in `readme.txt` and `README.md`, and the tag name must all be the same. *Stops if they differ.* -3. **Check `vendor-prefixed/`.** Rebuilds it from `composer.lock` and compares it with the committed copy, ignoring the Strauss date lines. Then puts the committed copy back, so the release ships exactly what is in git. *Stops if out of date.* +3. **Check `vendor-prefixed/`.** Rebuilds it from `composer.lock` and compares it with the committed copy, ignoring the Strauss date lines and the order of the classmap entries. Then puts the committed copy back, so the release ships exactly what is in git. *Stops if out of date.* 4. **Build assets.** `npm ci` and `npm run build` produce `dist/`. 5. **Upload to WordPress.org.** The 10up action copies the plugin to SVN, without the files in `.distignore`. *Skipped in a dry run.* 6. **Build the zip** from the same checked-out files, without the files in `.distignore`. Because it uses the tag, a rerun always gives the zip for that version. From 3887b069957aba2d074f9f4143abed7a2c5d2d71 Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Fri, 13 Nov 2026 07:29:47 +0100 Subject: [PATCH 5/5] refactor: added explicit write permissions to the Dependabot automerge workflow --- .github/workflows/dependabot-automerge.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index a95ecbc..194a789 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -6,4 +6,8 @@ jobs: # Composer PRs are merged by dependabot-vendor-prefixed.yml after vendor-prefixed/ is rebuilt. if: ${{ !startsWith(github.head_ref, 'dependabot/composer/') }} uses: yardinternet/workflows/.github/workflows/dependabot-automerge.yml@main + # The reusable workflow requests these; grant them explicitly so a read-only default token can't block the call. + permissions: + contents: write + pull-requests: write secrets: inherit