Easily Commit and Tag your WordPress Plugin to the WordPress.org SVN Repo From Finder on a Mac

If you develop WordPress plugins and submit them to the WordPress.org plugin repository, you already know that releasing an update through SVN isn’t particularly difficult. It is, however, repetitive.

Normally I have to open Terminal, navigate to the plugin’s SVN directory, check the version, update the stable tag, add any new files, commit everything, create the new tag, and then make sure the tag actually made it to the WordPress.org repository.

I wanted to simplify that process, so what I ended up creating is a Mac Automator Quick Action called Commit WP Plugin.

Now I can simply right-click a WordPress plugin folder in Finder, choose Commit WP Plugin, and the script does most of the release process automatically.

It will:

  • Detect the WordPress plugin SVN repository.
  • Find the main plugin PHP file.
  • Read the plugin version from the Version: header.
  • Check the Stable Tag: in readme.txt.
  • Automatically update the Stable Tag if necessary.
  • Run an SVN update before committing.
  • Detect SVN conflicts.
  • Automatically add new files.
  • Detect deleted files and schedule them for deletion.
  • Commit changes in trunk and assets.
  • Create the new SVN tag based on the plugin version.
  • Refuse to overwrite an existing release tag.
  • Verify that the new tag exists remotely.
  • Display a Mac notification when the release finishes.

This is the setup I use.

 


Expected WordPress Plugin SVN Structure

This assumes you already have your WordPress.org SVN repository checked out locally.

The folder should look something like this:

my-plugin/
├── assets/
├── tags/
│   ├── 1.0.0/
│   └── 1.0.1/
└── trunk/
    ├── my-plugin.php
    ├── readme.txt
    ├── includes/
    └── ...

The script will work if you right-click either:

my-plugin/

or:

my-plugin/trunk/

I personally right-click the main plugin folder.


Step 1: Create the Release Script

First create a bin directory in your Mac home folder if you don’t already have one.

Open Terminal and run:

mkdir -p ~/bin

Now create the script:

nano ~/bin/commit-wp-plugin

Paste the following script into the file:

#!/bin/zsh

# ============================================================
# WordPress.org Plugin SVN Release Script
#
# Usage:
#   commit-wp-plugin "/path/to/plugin-svn-folder"
#
# You can select either:
#   plugin-folder/
# or:
#   plugin-folder/trunk/
#
# Expected SVN structure:
#
# plugin-folder/
# ├── assets/
# ├── tags/
# └── trunk/
#
# The script:
#   1. Finds the main plugin PHP file
#   2. Reads its Version: header
#   3. Updates Stable Tag: in trunk/readme.txt if necessary
#   4. Updates SVN working copy
#   5. Adds new files in trunk/assets
#   6. Schedules missing files for deletion
#   7. Commits trunk/assets
#   8. Creates remote SVN tag tags/VERSION from remote trunk
#   9. Updates local tags directory
# ============================================================

set -u

# ------------------------------------------------------------
# SETTINGS
# ------------------------------------------------------------

CONFIRM_BEFORE_RELEASE=1

SVN="/usr/bin/svn"

# ------------------------------------------------------------
# COLORS
# ------------------------------------------------------------

RED=$'\e[31m'
GREEN=$'\e[32m'
YELLOW=$'\e[33m'
BLUE=$'\e[34m'
BOLD=$'\e[1m'
RESET=$'\e[0m'

# ------------------------------------------------------------
# FUNCTIONS
# ------------------------------------------------------------

die() {
    echo
    echo "${RED}${BOLD}ERROR:${RESET} $1"
    echo
    exit 1
}

success() {
    echo "${GREEN}✓${RESET} $1"
}

info() {
    echo "${BLUE}→${RESET} $1"
}

warning() {
    echo "${YELLOW}!${RESET} $1"
}

add_unversioned() {
    local dir="$1"

    [[ -e "$dir" ]] || return 0

    "$SVN" status "$dir" 2>/dev/null |
        /usr/bin/sed -n 's/^?       //p' |
        while IFS= read -r path; do

            [[ -z "$path" ]] && continue

            if [[ "$(basename "$path")" == ".DS_Store" ]]; then
                continue
            fi

            info "Adding: $path"

            "$SVN" add "$path" || die "Could not svn add: $path"
        done
}

schedule_missing_files() {
    local dir="$1"

    [[ -e "$dir" ]] || return 0

    "$SVN" status "$dir" 2>/dev/null |
        /usr/bin/sed -n 's/^!       //p' |
        while IFS= read -r path; do

            [[ -z "$path" ]] && continue

            info "Scheduling deletion: $path"

            "$SVN" delete --force "$path" ||
                die "Could not schedule deletion: $path"
        done
}

# ------------------------------------------------------------
# CHECK INPUT
# ------------------------------------------------------------

if [[ $# -lt 1 ]]; then
    die "No plugin folder was supplied."
fi

SELECTED_PATH="${1:A}"

if [[ ! -d "$SELECTED_PATH" ]]; then
    die "Selected item is not a folder: $SELECTED_PATH"
fi

if [[ "$(basename "$SELECTED_PATH")" == "trunk" ]]; then
    ROOT="${SELECTED_PATH:h}"
else
    ROOT="$SELECTED_PATH"
fi

TRUNK="$ROOT/trunk"
TAGS="$ROOT/tags"
ASSETS="$ROOT/assets"
README="$TRUNK/readme.txt"

echo
echo "${BOLD}============================================${RESET}"
echo "${BOLD}       WordPress Plugin SVN Release${RESET}"
echo "${BOLD}============================================${RESET}"
echo
echo "Repository:"
echo "  $ROOT"
echo

# ------------------------------------------------------------
# CHECK SVN
# ------------------------------------------------------------

if [[ ! -x "$SVN" ]]; then
    SVN="$(command -v svn 2>/dev/null || true)"
fi

[[ -n "$SVN" ]] || die "Subversion (svn) is not installed."

"$SVN" info "$ROOT" >/dev/null 2>&1 ||
    die "This does not appear to be an SVN working copy."

[[ -d "$TRUNK" ]] ||
    die "Could not find trunk/: $TRUNK"

# ------------------------------------------------------------
# FIND MAIN PLUGIN FILE
# ------------------------------------------------------------

info "Finding main plugin file..."

PLUGIN_FILES=()

for file in "$TRUNK"/*.php(N); do

    if /usr/bin/head -n 100 "$file" |
        /usr/bin/grep -Eiq '^[[:space:]]*\*?[[:space:]]*Plugin Name[[:space:]]*:'; then

        PLUGIN_FILES+=("$file")
    fi
done

if (( ${#PLUGIN_FILES[@]} == 0 )); then
    die "Could not find a PHP file in trunk containing a Plugin Name: header."
fi

if (( ${#PLUGIN_FILES[@]} > 1 )); then
    echo
    warning "More than one possible main plugin file was found:"
    echo

    for file in "${PLUGIN_FILES[@]}"; do
        echo "  - $(basename "$file")"
    done

    die "Release aborted because the main plugin file is ambiguous."
fi

MAIN_FILE="${PLUGIN_FILES[1]}"

# ------------------------------------------------------------
# GET PLUGIN NAME
# ------------------------------------------------------------

PLUGIN_NAME=$(
    /usr/bin/head -n 100 "$MAIN_FILE" |
    /usr/bin/perl -ne '
        if (/^\s*\*?\s*Plugin Name\s*:\s*(.*?)\s*$/i) {
            print $1;
            exit;
        }
    '
)

[[ -n "$PLUGIN_NAME" ]] ||
    PLUGIN_NAME="$(basename "$ROOT")"

# ------------------------------------------------------------
# GET VERSION
# ------------------------------------------------------------

VERSION=$(
    /usr/bin/head -n 100 "$MAIN_FILE" |
    /usr/bin/perl -ne '
        if (/^\s*\*?\s*Version\s*:\s*(.*?)\s*$/i) {
            print $1;
            exit;
        }
    '
)

VERSION="${VERSION//$'\r'/}"

[[ -n "$VERSION" ]] ||
    die "Could not find Version: in $(basename "$MAIN_FILE")."

if [[ ! "$VERSION" =~ '^[0-9]+(\.[0-9]+)*$' ]]; then
    die "Version '$VERSION' is not a standard numeric WordPress.org release version."
fi

success "Plugin: $PLUGIN_NAME"
success "Main file: $(basename "$MAIN_FILE")"
success "Version: $VERSION"

echo

# ------------------------------------------------------------
# GET REPOSITORY URL
# ------------------------------------------------------------

REPO_URL=$(
    "$SVN" info "$ROOT" |
    /usr/bin/awk -F': ' '/^URL: / {
        print $2;
        exit
    }'
)

[[ -n "$REPO_URL" ]] ||
    die "Could not determine SVN repository URL."

REPO_URL="${REPO_URL%/}"

echo "Remote repository:"
echo "  $REPO_URL"
echo

# ------------------------------------------------------------
# MAKE SURE TAG DOES NOT ALREADY EXIST
# ------------------------------------------------------------

info "Checking for existing tag $VERSION..."

if "$SVN" ls "$REPO_URL/tags/$VERSION" >/dev/null 2>&1; then

    die "SVN tag $VERSION already exists.

Existing WordPress.org release tags should not be modified.
Increase the plugin Version: before creating another release."
fi

success "Tag $VERSION is available."

# ------------------------------------------------------------
# UPDATE TRUNK
# ------------------------------------------------------------

echo
info "Updating SVN trunk..."

"$SVN" update "$TRUNK" ||
    die "svn update failed for trunk."

if "$SVN" info "$ASSETS" >/dev/null 2>&1; then
    info "Updating SVN assets..."
    "$SVN" update "$ASSETS" ||
        die "svn update failed for assets."
fi

# ------------------------------------------------------------
# CHECK FOR CONFLICTS
# ------------------------------------------------------------

STATUS=$("$SVN" status "$TRUNK" "$ASSETS" 2>/dev/null || true)

if echo "$STATUS" |
    /usr/bin/awk '
        substr($0,1,7) ~ /C/ {
            found=1
        }

        END {
            exit !found
        }
    '; then

    echo
    echo "$STATUS"

    die "SVN conflicts were detected. Resolve them before releasing."
fi

# ------------------------------------------------------------
# UPDATE STABLE TAG
# ------------------------------------------------------------

[[ -f "$README" ]] ||
    die "Could not find trunk/readme.txt."

CURRENT_STABLE_TAG=$(
    /usr/bin/grep -Ei \
        '^[[:space:]]*Stable[[:space:]]+tag[[:space:]]*:' \
        "$README" |
    /usr/bin/head -n 1 |
    /usr/bin/sed -E \
        's/^[^:]+:[[:space:]]*//'
)

CURRENT_STABLE_TAG="${CURRENT_STABLE_TAG//$'\r'/}"

if [[ -z "$CURRENT_STABLE_TAG" ]]; then
    die "No Stable Tag: entry was found in trunk/readme.txt."
fi

if [[ "$CURRENT_STABLE_TAG" != "$VERSION" ]]; then

    warning "Stable Tag is currently: $CURRENT_STABLE_TAG"
    info "Changing Stable Tag to: $VERSION"

    VERSION="$VERSION" /usr/bin/perl -pi -e '
        if (/^\s*Stable\s+tag\s*:/i) {
            s/^(\s*Stable\s+tag\s*:\s*).*$/$1$ENV{VERSION}/i;
        }
    ' "$README"

    success "Updated readme.txt Stable Tag."
else
    success "Stable Tag already matches $VERSION."
fi

# ------------------------------------------------------------
# REMOVE FINDER JUNK
# ------------------------------------------------------------

/usr/bin/find "$TRUNK" \
    -name '.DS_Store' \
    -type f \
    -delete 2>/dev/null || true

if [[ -d "$ASSETS" ]]; then
    /usr/bin/find "$ASSETS" \
        -name '.DS_Store' \
        -type f \
        -delete 2>/dev/null || true
fi

# ------------------------------------------------------------
# ADD NEW FILES
# ------------------------------------------------------------

echo
info "Checking for new files..."

add_unversioned "$TRUNK"

if [[ -d "$ASSETS" ]]; then

    if ! "$SVN" info "$ASSETS" >/dev/null 2>&1; then
        info "Adding assets directory..."
        "$SVN" add "$ASSETS" ||
            die "Could not add assets directory."
    else
        add_unversioned "$ASSETS"
    fi
fi

# ------------------------------------------------------------
# SCHEDULE MISSING FILES AS DELETED
# ------------------------------------------------------------

schedule_missing_files "$TRUNK"

if [[ -d "$ASSETS" ]]; then
    schedule_missing_files "$ASSETS"
fi

# ------------------------------------------------------------
# DISPLAY FINAL STATUS
# ------------------------------------------------------------

echo
echo "${BOLD}Changes that will be committed:${RESET}"
echo

COMMIT_TARGETS=("$TRUNK")

if [[ -d "$ASSETS" ]]; then
    COMMIT_TARGETS+=("$ASSETS")
fi

FINAL_STATUS=$("$SVN" status "${COMMIT_TARGETS[@]}")

if [[ -n "$FINAL_STATUS" ]]; then
    echo "$FINAL_STATUS"
else
    echo "  No working-copy changes."
fi

echo
echo "${BOLD}Release:${RESET}"
echo "  Plugin:  $PLUGIN_NAME"
echo "  Version: $VERSION"
echo "  Tag:     tags/$VERSION"
echo

# ------------------------------------------------------------
# CONFIRM
# ------------------------------------------------------------

if (( CONFIRM_BEFORE_RELEASE == 1 )); then

    echo -n "Commit and release version $VERSION? [y/N] "

    read REPLY

    case "$REPLY" in
        y|Y|yes|YES|Yes)
            ;;
        *)
            echo
            warning "Release cancelled."
            exit 2
            ;;
    esac
fi

# ------------------------------------------------------------
# COMMIT TRUNK / ASSETS
# ------------------------------------------------------------

echo

FINAL_STATUS=$("$SVN" status "${COMMIT_TARGETS[@]}")

if [[ -n "$FINAL_STATUS" ]]; then

    info "Committing version $VERSION to WordPress.org..."

    "$SVN" commit \
        "${COMMIT_TARGETS[@]}" \
        -m "Release $PLUGIN_NAME $VERSION" ||
        die "SVN commit failed."

    success "Trunk/assets committed."

else

    info "There are no trunk/assets changes to commit."
    info "Using the existing committed trunk revision."

fi

# ------------------------------------------------------------
# CREATE REMOTE RELEASE TAG
# ------------------------------------------------------------

echo
info "Creating WordPress.org release tag $VERSION..."

"$SVN" copy \
    "$REPO_URL/trunk" \
    "$REPO_URL/tags/$VERSION" \
    -m "Tag version $VERSION" ||
    die "Could not create SVN tag $VERSION."

success "Created tags/$VERSION."

# ------------------------------------------------------------
# UPDATE LOCAL TAGS
# ------------------------------------------------------------

if [[ -d "$TAGS" ]]; then

    echo
    info "Updating local tags directory..."

    "$SVN" update "$TAGS" ||
        warning "Release succeeded, but the local tags directory could not be updated."

fi

# ------------------------------------------------------------
# VERIFY REMOTE TAG
# ------------------------------------------------------------

echo
info "Verifying remote release..."

if ! "$SVN" ls "$REPO_URL/tags/$VERSION" >/dev/null 2>&1; then
    die "The release tag could not be verified after creation."
fi

success "Remote tag verified."

# ------------------------------------------------------------
# FINISHED
# ------------------------------------------------------------

echo
echo "${GREEN}${BOLD}============================================${RESET}"
echo "${GREEN}${BOLD}        RELEASE SUCCESSFUL: $VERSION${RESET}"
echo "${GREEN}${BOLD}============================================${RESET}"
echo
echo "$PLUGIN_NAME $VERSION has been committed and tagged."
echo
echo "Remote tag:"
echo "  $REPO_URL/tags/$VERSION"
echo

/usr/bin/osascript -e \
    "display notification \"$PLUGIN_NAME $VERSION was committed and tagged.\" with title \"WordPress Plugin Released\"" \
    >/dev/null 2>&1 || true

exit 0

Save the file by pressing:

Control + O

Press Enter, and then exit Nano with:

Control + X

Now make the script executable:

chmod +x ~/bin/commit-wp-plugin

Step 2: Test the Script Manually

Before adding this to Finder, I recommend testing it manually.

Run:

~/bin/commit-wp-plugin "/path/to/plugin"

For example:

~/bin/commit-wp-plugin "/Users/yourname/WordPress/my-plugin"

The script should detect the plugin and display something similar to:

============================================
       WordPress Plugin SVN Release
============================================

Plugin: My WordPress Plugin
Main file: my-plugin.php
Version: 1.2.3

Before anything is released, it asks:

Commit and release version 1.2.3? [y/N]

This gives me one final opportunity to check everything before sending the release to WordPress.org.


Step 3: Create the Mac Automator Quick Action

Now we can make the script available when right-clicking a folder in Finder.

Open:

Automator

Choose:

New Document → Quick Action

At the top of the Automator window set:

Workflow receives current: folders
in: Finder

Search the Automator actions for:

Run Shell Script

Drag Run Shell Script into the workflow.

Set:

Shell: /bin/zsh

and:

Pass input: as arguments

Step 4: Add the Automator Shell Script

Paste this into the Run Shell Script action:

for folder in "$@"; do

    /usr/bin/osascript - "$folder" <<'APPLESCRIPT'

on run argv

    set folderPath to item 1 of argv
    set scriptPath to (POSIX path of (path to home folder)) & "bin/commit-wp-plugin"
    set commandText to quoted form of scriptPath & " " & quoted form of folderPath

    if application "Terminal" is running then
        tell application "Terminal"
            do script commandText in front window
            activate
        end tell
    else
        tell application "Terminal"
            do script commandText
            activate
        end tell
    end if

end run

APPLESCRIPT

done

One important detail here is how Terminal is opened.

Originally I used:

activate
do script commandText

That caused two Terminal windows to open when Terminal wasn’t already running.

The version above checks whether Terminal is already open first.

If Terminal is closed, it opens one window.

If Terminal is already running, it uses the current front Terminal window.


Step 5: Save the Quick Action

Save the Automator Quick Action as:

Commit WP Plugin

That’s it.

The option should now appear in Finder.

Depending on your version of macOS, you may see it under:

Right-click folder
→ Quick Actions
→ Commit WP Plugin

or:

Right-click folder
→ Services
→ Commit WP Plugin

Releasing a Plugin

Let’s say the main plugin file contains:

/**
 * Plugin Name: My Plugin
 * Version: 1.4.0
 */

And the SVN repository looks like:

my-plugin/
├── assets/
├── tags/
│   ├── 1.2.0/
│   └── 1.3.0/
└── trunk/
    ├── my-plugin.php
    └── readme.txt

I right-click:

my-plugin

and select:

Commit WP Plugin

The script detects:

Plugin: My Plugin
Version: 1.4.0
Tag: tags/1.4.0

Stable Tag Is Updated Automatically

If the plugin PHP file says:

Version: 1.4.0

but readme.txt still contains:

Stable Tag: 1.3.0

the script changes it automatically to:

Stable Tag: 1.4.0

That removes one of the easiest things to forget during a WordPress.org plugin release.


New Files Are Automatically Added to SVN

Another thing I regularly forget when working with SVN is that simply putting a file in the folder doesn’t mean SVN is tracking it.

For example:

?       trunk/includes/new-feature.php

Normally I would have to run:

svn add trunk/includes/new-feature.php

The release script detects untracked files and runs svn add for me.


Deleted Files Are Handled Too

If I delete a file through Finder or my code editor, SVN may show:

!       trunk/includes/old-feature.php

The script detects that and schedules the file for deletion from SVN.

Essentially it performs:

svn delete

before committing.


It Does Not Modify Old Release Tags

One thing I specifically wanted to prevent was accidentally modifying an existing WordPress release.

If I’m releasing:

Version: 1.4.0

the script checks:

tags/1.4.0

before doing anything.

If the tag already exists, the release stops.

You’ll get an error telling you to increase the version number instead.

I prefer treating published plugin tags as immutable releases rather than modifying an existing release after it has already been published.


The Tag Comes From the Committed Trunk

Another important part of this setup is that the release tag isn’t simply copied from my local files.

First the script commits trunk.

Then it creates the tag on the SVN server using:

svn copy \
    REPOSITORY/trunk \
    REPOSITORY/tags/1.4.0 \
    -m "Tag version 1.4.0"

That means the release tag is created from the actual committed version of trunk.

I prefer doing it this way because there is less chance of the release tag containing something different from what I actually committed.


It Checks for SVN Conflicts Before Releasing

Before committing, the script runs an SVN update.

If SVN reports conflicts, the release stops.

For example:

C       trunk/my-plugin.php

Instead of trying to push through the conflict, the script requires me to resolve it first.

For a release script, I think failing safely is a lot better than trying to automatically guess how an SVN conflict should be resolved.


What the Final Release Looks Like

Before anything is committed, I see a summary like:

Changes that will be committed:

M       trunk/my-plugin.php
M       trunk/readme.txt
A       trunk/includes/new-feature.php
M       assets/banner-1544x500.png

Release:
  Plugin:  My Plugin
  Version: 1.4.0
  Tag:     tags/1.4.0

Commit and release version 1.4.0? [y/N]

If everything looks correct, I enter:

y

The script then:

Commits trunk
Commits assets
Creates tags/1.4.0
Updates the local tags directory
Verifies the remote tag

When everything succeeds, I get:

============================================
        RELEASE SUCCESSFUL: 1.4.0
============================================

and macOS displays a notification letting me know the plugin was released.


Installing SVN on macOS

If the Mac doesn’t have the svn command available, install Apple’s Command Line Tools:

xcode-select --install

You can check whether SVN is installed with:

svn --version

WordPress.org SVN Login

The first time SVN needs to communicate with WordPress.org, you may be prompted for your WordPress.org username and password.

Once SVN has your credentials stored, subsequent releases normally use those saved credentials.

I recommend testing the repository manually before relying on the Automator action:

svn info

and:

svn update

from inside the plugin SVN checkout.

That makes sure authentication is working before Automator gets involved.


My Workflow Now

My WordPress plugin release process is basically:

  1. Finish the plugin changes.
  2. Update the Version: in the main plugin PHP file.
  3. Update the changelog in readme.txt.
  4. Right-click the plugin SVN folder.
  5. Choose Commit WP Plugin.
  6. Review the SVN changes.
  7. Press y.

The script handles the repetitive SVN work from there.

It’s a small automation, but if you maintain multiple WordPress plugins it saves a surprising amount of time and eliminates several easy-to-miss release steps.

It also means I don’t have to remember the exact SVN tag command every time I publish an update.

For me, that’s the entire point of automating it: I still get to review what is about to be released, but I don’t have to manually perform the same release commands every single time.