From 32c662c0671e22c578de9db210f493c8a16e085d Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Fri, 6 Nov 2026 07:25:50 +0100 Subject: [PATCH 1/3] 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 8579cded0c46969a7c016ffd985c118de5e8ad94 Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Tue, 10 Nov 2026 08:25:33 +0100 Subject: [PATCH 2/3] 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 223bd12b47486dd870e4e8a048c0626618e5bff6 Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Tue, 10 Nov 2026 09:50:56 +0100 Subject: [PATCH 3/3] 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 98bfaa3..e7d46c9 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.