diff --git a/.github/workflows/wordpress-org-release.yml b/.github/workflows/wordpress-org-release.yml new file mode 100644 index 00000000..03e8122b --- /dev/null +++ b/.github/workflows/wordpress-org-release.yml @@ -0,0 +1,261 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: wordpress.org publishing + +on: + workflow_call: + inputs: + SVN_PLUGIN_SLUG: + description: "WordPress.org plugin slug (e.g., 'woocommerce')" + type: string + required: true + + PLUGIN_VERSION: + description: "Plugin version to publish (MAJOR.MINOR.PATCH)" + type: string + required: true + + GIT_REF: + description: "Git ref to publish (tag, branch, or commit)" + type: string + required: true + + DRY_RUN: + description: "Create artifact from trunk instead of committing to SVN" + type: boolean + default: false + required: false + + UPDATE_TRUNK_ONLY: + description: "Only update trunk at wordpress.org, but don't create a new tag (version)" + type: boolean + default: true + required: false + + secrets: + SVN_USERNAME: + required: true + + SVN_PASSWORD: + required: true + + GITHUB_USER_SSH_KEY: + required: true + +jobs: + update_svn_from_git: + env: + SVN_REPO_PATH: "${{ github.workspace }}/svn_repository" + SVN_REPO_ROOT: "${{ github.workspace }}/svn_repository/${{ inputs.SVN_PLUGIN_SLUG }}" + runs-on: ubuntu-latest + steps: + - name: Install required tools + run: | + sudo apt-get update -y + sudo apt-get install -y subversion rsync + + - name: Validate plugin slug + env: + SVN_PLUGIN_SLUG: ${{ inputs.SVN_PLUGIN_SLUG }} + run: | + set -euo pipefail + + if [[ ! "$SVN_PLUGIN_SLUG" =~ ^[a-z0-9-]+$ ]]; then + echo "❌ Invalid plugin slug: $SVN_PLUGIN_SLUG" + echo " WordPress.org slugs may only contain lowercase letters, numbers, and hyphens" + exit 1 + fi + + echo "✅ Valid plugin slug: $SVN_PLUGIN_SLUG" + + - name: Validate versions + env: + PLUGIN_VERSION: ${{ inputs.PLUGIN_VERSION }} + GIT_REF: ${{ inputs.GIT_REF }} + run: | + set -euo pipefail + + if [[ ! "$PLUGIN_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "❌ WordPress.org expects version formatted as MAJOR.MINOR.PATCH" + echo " Three groups of numbers separated by dots" + echo " Each group: either 0 or digits not starting with 0" + echo " Received: $PLUGIN_VERSION" + exit 1 + fi + + echo "VALIDATED_PLUGIN_VERSION=$PLUGIN_VERSION" >> $GITHUB_ENV + echo "✅ Valid WordPress.org version: $PLUGIN_VERSION" + + if ! git check-ref-format --allow-onelevel "$GIT_REF"; then + echo "❌ Invalid Git ref name: $GIT_REF" + exit 1 + fi + # We do not create VALIDATED_GIT_REF env var here because actions/checkout action we are going to use it with cannot take ref from vars anyway. + # But since it is validated, it is safe to use it directly from user input. + + - name: Checkout git repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.GIT_REF }} + ssh-key: ${{ secrets.GITHUB_USER_SSH_KEY }} + + - name: Verify version consistency + run: | + set -euo pipefail + + PLUGIN_FILE=$(find . -maxdepth 1 -type f -name '*.php' -exec grep -l 'Plugin Name:' {} + | head -1) + if [ -z "$PLUGIN_FILE" ]; then + echo "❌ No plugin file with 'Plugin Name:' header found" + exit 1 + fi + + EXTRACTION_ERROR=0 + + PLUGIN_FILE_VERSION=$(grep -m1 'Version:' "$PLUGIN_FILE" | grep -oP '[\d]+\.[\d]+\.[\d]+') + if [ -z "$PLUGIN_FILE_VERSION" ]; then + echo "❌ Could not extract version from $PLUGIN_FILE" + EXTRACTION_ERROR=1 + fi + + if [ ! -f readme.txt ]; then + echo "❌ readme.txt not found" + EXTRACTION_ERROR=1 + else + README_VERSION=$(grep -m1 'Stable tag:' readme.txt | grep -oP '[\d]+\.[\d]+\.[\d]+' || true) + if [ -z "$README_VERSION" ]; then + echo "❌ Could not extract 'Stable tag:' from readme.txt" + EXTRACTION_ERROR=1 + fi + fi + + [ "$EXTRACTION_ERROR" = "1" ] && exit 1 + + MISMATCH=0 + + if [ "$VALIDATED_PLUGIN_VERSION" != "$PLUGIN_FILE_VERSION" ]; then + echo "❌ Input version ($VALIDATED_PLUGIN_VERSION) does not match $PLUGIN_FILE ($PLUGIN_FILE_VERSION)" + MISMATCH=1 + fi + if [ "$VALIDATED_PLUGIN_VERSION" != "$README_VERSION" ]; then + echo "❌ Input version ($VALIDATED_PLUGIN_VERSION) does not match readme.txt Stable tag ($README_VERSION)" + MISMATCH=1 + fi + + [ "$MISMATCH" = "1" ] && exit 1 + + echo "✅ Version $VALIDATED_PLUGIN_VERSION is consistent across inputs, plugin file, and readme.txt" + + - name: Checkout SVN repository + run: | + set -euo pipefail + mkdir -p "$SVN_REPO_PATH" && cd "$SVN_REPO_PATH" + + # We only do a full checkout of trunk here, leaving other directories empty. + # Tags directory can take gigabytes for older plugins, and we don't need them for publishing. + + svn checkout "https://plugins.svn.wordpress.org/${{ inputs.SVN_PLUGIN_SLUG }}" --depth immediates + cd "$SVN_REPO_ROOT" && svn update trunk --set-depth infinity + + - name: Verify version doesn't exist in SVN + if: inputs.UPDATE_TRUNK_ONLY != true + run: | + set -euo pipefail + + cd "${SVN_REPO_ROOT}/tags" + svn update --set-depth immediates + + if [ -d "$VALIDATED_PLUGIN_VERSION" ]; then + echo "❌ Version $VALIDATED_PLUGIN_VERSION already exists in WordPress.org!" + echo " Check: https://plugins.svn.wordpress.org/${{ inputs.SVN_PLUGIN_SLUG }}/tags/" + exit 1 + fi + + echo "✅ Version $VALIDATED_PLUGIN_VERSION is available" + + - name: Synchronize SVN repository with Git + working-directory: ${{ github.workspace }} #Make sure we are in the right place before start sync + run: | + set -euo pipefail + + touch .distignore # Ensure file exists so --exclude-from doesn't fail + + rsync -rc \ + --delete \ + --delete-excluded \ + --exclude='.git' \ + --exclude='.svn' \ + --exclude='auth.json' \ + --exclude='.npmrc' \ + --exclude='.env' \ + --exclude='.env.*' \ + --exclude='.distignore' \ + --exclude-from='.distignore' \ + ./ "$SVN_REPO_ROOT/trunk/" + + - name: Update SVN tracking + run: | + set -euo pipefail + + cd "${SVN_REPO_ROOT}/trunk" + svn add . --force + + #Only run if there's anything to delete + if svn status | grep -q '^!'; then + svn status | grep '^!' | cut -c9- | while IFS= read -r file; do + svn delete "$file@" # @ suffix handles files with @ in name + done + fi + + - name: Commit changes to SVN repository + if: inputs.DRY_RUN != true + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + run: | + set -euo pipefail + + [ -d "$SVN_REPO_ROOT" ] || { echo "❌ SVN_REPO_ROOT not found"; exit 1; } + cd "$SVN_REPO_ROOT" + + svn status trunk | grep -q '^[ADMR!~]' || { echo "❌ No changes in trunk"; exit 1; } + + # WordPress.org SVN doesn't support SSH keys or tokens. + # Keeping credentials as step-level env vars with --no-auth-cache is the best available option. + echo '🚀 Committing...' + + svn commit trunk \ + --username "$SVN_USERNAME" \ + --password "$SVN_PASSWORD" \ + --no-auth-cache \ + --non-interactive \ + -m "Update trunk to version ${VALIDATED_PLUGIN_VERSION}" + + - name: Create a new tag + if: inputs.UPDATE_TRUNK_ONLY == false && inputs.DRY_RUN != true + env: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + run: | + set -euo pipefail + + echo "🚀 Publishing version ${VALIDATED_PLUGIN_VERSION}" + + svn copy \ + "https://plugins.svn.wordpress.org/${{ inputs.SVN_PLUGIN_SLUG }}/trunk" \ + "https://plugins.svn.wordpress.org/${{ inputs.SVN_PLUGIN_SLUG }}/tags/${VALIDATED_PLUGIN_VERSION}" \ + --username "$SVN_USERNAME" \ + --password "$SVN_PASSWORD" \ + --no-auth-cache \ + --non-interactive \ + -m "Tagging version ${VALIDATED_PLUGIN_VERSION}" + + echo "✅ Version ${VALIDATED_PLUGIN_VERSION} was published to WordPress.org" + + - name: Compress and upload trunk contents as an artifact + if: inputs.DRY_RUN == true + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.SVN_PLUGIN_SLUG }} + path: ${{ env.SVN_REPO_ROOT }}/trunk + include-hidden-files: true + compression-level: 1 #Minimal compression - we don't want to waste resources for this diff --git a/docs/wordpress-org-release.md b/docs/wordpress-org-release.md new file mode 100644 index 00000000..770c7ba3 --- /dev/null +++ b/docs/wordpress-org-release.md @@ -0,0 +1,163 @@ +# WordPress.org Release + +This reusable workflow publishes a WordPress plugin to the [WordPress.org plugin directory](https://wordpress.org/plugins/) via SVN. + +To achieve that, this workflow: + +1. Validates that `PLUGIN_VERSION` is formatted as `MAJOR.MINOR.PATCH` +2. Checks out the Git repository at `GIT_REF` +3. Verifies that the version in the plugin file header and `readme.txt` `Stable tag` both match `PLUGIN_VERSION` +4. Checks out the WordPress.org SVN repository (trunk is fully checked out; tag contents are never fetched — only tag names are listed when needed for the version check) +5. Verifies the version does not already exist as an SVN tag (skipped when `UPDATE_TRUNK_ONLY=true`) +6. Synchronizes the Git working directory to SVN trunk via `rsync`, respecting `.distignore` and excluding sensitive files like `auth.json`, `.env`, and `.npmrc` +7. Commits trunk to SVN (or uploads it as an artifact in `DRY_RUN` mode) +8. Creates an SVN tag from trunk (skipped when `UPDATE_TRUNK_ONLY=true` or `DRY_RUN=true`) + +> [!NOTE] +> This workflow intentionally fails if the version already exists as an SVN tag. There is no amendment flow. + +> [!IMPORTANT] +> The plugin's SVN repository must already exist on WordPress.org before running this workflow — including in `DRY_RUN` mode. Initial plugin submission requires a separate manual process via the [WordPress.org plugin submission form](https://wordpress.org/plugins/developers/add/). + +## Simple usage example + +This workflow cannot be triggered directly. Create a workflow file in your plugin's repository that calls it via `uses:`, as shown below. + +```yml +name: Publish to WordPress.org +on: + workflow_dispatch: + inputs: + PLUGIN_VERSION: + description: 'Version to publish (MAJOR.MINOR.PATCH)' + required: true + GIT_REF: + description: 'Git tag or branch to publish' + required: true + UPDATE_TRUNK_ONLY: + description: 'Only update trunk, skip tag creation' + type: boolean + default: true +jobs: + publish: + uses: inpsyde/reusable-workflows/.github/workflows/wordpress-org-release.yml@main + with: + SVN_PLUGIN_SLUG: my-plugin + PLUGIN_VERSION: ${{ inputs.PLUGIN_VERSION }} + GIT_REF: ${{ inputs.GIT_REF }} + UPDATE_TRUNK_ONLY: ${{ inputs.UPDATE_TRUNK_ONLY == 'true' }} + secrets: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + GITHUB_USER_SSH_KEY: ${{ secrets.GITHUB_USER_SSH_KEY }} +``` + +## Trigger on Git tag push + +If your release process creates a Git tag named after the version (e.g. `1.2.3`), the version can be derived directly from the tag — no manual input needed: + +```yml +name: Publish to WordPress.org +on: + push: + tags: ['[0-9]*.[0-9]*.[0-9]*'] +jobs: + publish: + uses: inpsyde/reusable-workflows/.github/workflows/wordpress-org-release.yml@main + with: + SVN_PLUGIN_SLUG: my-plugin + PLUGIN_VERSION: ${{ github.ref_name }} + GIT_REF: ${{ github.ref }} + UPDATE_TRUNK_ONLY: false + secrets: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + GITHUB_USER_SSH_KEY: ${{ secrets.GITHUB_USER_SSH_KEY }} +``` + +> [!NOTE] +> This pattern requires tags to be named as bare versions (`1.2.3`, not `v1.2.3`). If your project uses `v`-prefixed tags, strip the prefix before passing it: `PLUGIN_VERSION: ${{ github.ref_name }}` would need to become a separate step that outputs `${GITHUB_REF_NAME#v}`. + +> [!WARNING] +> The version derived from the Git tag must match the `Version:` header in the main plugin file and the `Stable tag` in `readme.txt`. The workflow will fail if they don't all agree. Make sure these are updated in the same commit that the tag points to. + +## Advanced usage: requiring manual approval before tagging + +To require a manual approval step before the SVN tag is created, add an `environment:` key to the calling job and configure protection rules (required reviewers, wait timers, etc.) for that environment in your repository settings under **Settings → Environments**. + +```yml +name: Publish to WordPress.org +on: + workflow_dispatch: + inputs: + PLUGIN_VERSION: + description: 'Version to publish (MAJOR.MINOR.PATCH)' + required: true + GIT_REF: + description: 'Git tag or branch to publish' + required: true + UPDATE_TRUNK_ONLY: + description: 'Only update trunk, skip tag creation' + type: boolean + default: true +jobs: + publish: + environment: wordpress-org-release # enforces protection rules configured in repository settings + uses: inpsyde/reusable-workflows/.github/workflows/wordpress-org-release.yml@main + with: + SVN_PLUGIN_SLUG: my-plugin + PLUGIN_VERSION: ${{ inputs.PLUGIN_VERSION }} + GIT_REF: ${{ inputs.GIT_REF }} + UPDATE_TRUNK_ONLY: ${{ inputs.UPDATE_TRUNK_ONLY == 'true' }} + secrets: + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + GITHUB_USER_SSH_KEY: ${{ secrets.GITHUB_USER_SSH_KEY }} +``` + +> [!NOTE] +> The `environment:` key alone does nothing — protection rules must be explicitly configured in repository settings. An environment with no rules configured provides no approval gate. + +## Staged release (trunk first, tag later) + +To verify trunk on WordPress.org before publishing, run the workflow twice with the same inputs — first with `UPDATE_TRUNK_ONLY: true` (the default), then with `UPDATE_TRUNK_ONLY: false` once trunk looks correct. + +> [!WARNING] +> When `UPDATE_TRUNK_ONLY=true`, the version existence check is skipped. Do not leave trunk with a `Stable tag` pointing to a non-existent SVN tag — [WordPress.org will serve the plugin from trunk instead](https://developer.wordpress.org/plugins/wordpress-org/how-your-readme-txt-works/#how-the-readme-is-parsed). Keep `Stable tag` pointing to the latest published version. + +> [!NOTE] +> WordPress.org reads `readme.txt` from the tag that `Stable tag` points to, not from trunk. Updating `readme.txt` in trunk alone will not update the plugin page — a new tag must be created. + +## Configuration parameters + +### Inputs + +| Name | Required | Default | Description | +|---|---|---|---| +| `SVN_PLUGIN_SLUG` | yes | — | WordPress.org plugin slug (e.g. `my-plugin`) | +| `PLUGIN_VERSION` | yes | — | Version to publish, must be `MAJOR.MINOR.PATCH` | +| `GIT_REF` | yes | — | Git ref to publish (tag, branch, or commit SHA) | +| `DRY_RUN` | no | `false` | Upload trunk as an artifact instead of committing to SVN | +| `UPDATE_TRUNK_ONLY` | no | `true` | Sync trunk only; skip tag creation | + +### Secrets + +| Name | Required | Description | +|---|---|---| +| `SVN_USERNAME` | yes | WordPress.org SVN username | +| `SVN_PASSWORD` | yes | WordPress.org SVN password | +| `GITHUB_USER_SSH_KEY` | yes | SSH key used to check out the Git repository | + +> [!NOTE] +> WordPress.org SVN does not support SSH keys or tokens. The `SVN_USERNAME` and `SVN_PASSWORD` secrets are the only supported authentication method. + +## File exclusions + +The following files are always excluded from the SVN sync, regardless of `.distignore`: + +- `.git`, `.svn` +- `.env`, `.env.*` +- `auth.json`, `.npmrc` +- `.distignore` itself + +Additional exclusions can be specified via a `.distignore` file in the repository root (same format used by `wp dist-archive`).