Joomla remains a widely-used open-source CMS, and its extension ecosystem is as active as ever. If you're building plugins for Joomla 5.x and later, getting your head around the full publication pipeline — from local development through to the Joomla Extensions Directory takes some effort. This article walks through how I automated the release of my own Joomla plugin, covering the build script, the update server setup, and the submission process, so you don't have to piece it all together from scratch.
TL:DR – There's a lot to learn the first time, but once the update server pipeline is in place it becomes straightforward and repeatable. The plugin is live: Automatic Meta Description in the Joomla! Extensions Directory.
Contents
- A DevOps approach to plugin releases
- The release script
- Environment variables
- The script, step by step
- Guard: check required variables are set
- Remove macOS metadata files
- Prepare the output directory and define filenames
- Generate cryptographic hashes
- Generate the update XML manifest
- Upload to the update server
- Example output
- It works — and here's the proof
- Possible improvements for the future
- Next steps
When this process was first documented, Joomla 4 was the current stable release and Joomla 5 was newly arrived. By 2026, Joomla 5.x is the active long-term support branch, and the extension update infrastructure has matured alongside it. A few things worth noting if you're coming to this fresh:
- Joomla 3 is fully end-of-life. If your plugin still targets Joomla 3, it's time to migrate. The
targetplatformversion regex in your update XML should reflect Joomla 4.4 and 5.x only. - The Joomla Extensions Directory (JED) has tightened its review standards. Extensions must meet updated security and coding standards, and the review turnaround can take longer than it once did. Make sure your plugin passes the Joomla Patch Tester and follows the current Joomla Coding Standards before submission.
- GitHub Actions and similar CI/CD pipelines are now a popular alternative to hand-rolled shell scripts for plugin releases. The shell script approach described here is still perfectly valid — and arguably more transparent — but it's worth knowing that the community has moved toward automated pipeline tooling for larger projects.
- The
direnvtool referenced throughout this article continues to be actively maintained and works well on macOS (including Apple Silicon) and Linux in 2026. No change needed there. - SHA-384 and SHA-512 hash support in Joomla's update manifest is well established in Joomla 5.x, so the multi-hash approach in the script below is the right call.
A DevOps approach to plugin releases
The guiding principle here is straightforward: automate everything that can be automated, keep secrets out of scripts, and make each release a single repeatable command rather than a manual checklist.
A set of practices intended to reduce the time between committing a change to a system and the change being placed into normal production, while ensuring high quality.
I built this plugin for my own use initially and installed it manually. Once it was worth sharing publicly, I needed a proper release pipeline: something that would zip the plugin, generate the update manifest, upload both to an update server, and leave Joomla sites that already have the plugin able to update automatically. The shell script below does exactly that.
The release script
The script does four things in sequence:
- Zips up the plugin directory, stripping macOS Finder metadata files first.
- Generates SHA-256, SHA-384, and SHA-512 hashes of the ZIP.
- Writes the Joomla update XML manifest from those values.
- Uploads the ZIP and XML to the update server over SCP.
Environment variables
All plugin-specific and server-specific values live in environment variables, keeping the script itself generic and reusable. I manage these with direnv, which automatically loads a .envrc file when you enter a project directory. Install direnv, drop a .envrc in your plugin folder, then run direnv allow . once. After that, the variables are set whenever you're in that directory.
export PLUGIN_NAME="My Awesome Plugin"
export PLUGIN_ELEMENT="myplugin"
export PLUGIN_VERSION="1.2.3"
export PLUGIN_DESCRIPTION="This is an awesome Joomla plugin that does amazing things"
export PLUGIN_DIR="/path/to/plugin/files"
export OUTPUT_DIR="/path/to/output"
export UPDATE_SERVER="https://updates.example.com"
export SSH_USER="youruser"
export SSH_HOST="yourserver.com"
export REMOTE_PATH="/var/www/html/updates"
Each variable is described below:
PLUGIN_NAME— Human-readable name of the plugin.PLUGIN_ELEMENT— Internal element name used by Joomla.PLUGIN_VERSION— Semantic version string for this release.PLUGIN_DESCRIPTION— Short description included in the update manifest.PLUGIN_DIR— Local directory containing the plugin source files.OUTPUT_DIR— Where the ZIP and XML files are written locally before upload.UPDATE_SERVER— Public URL of the update server (used in the manifest).SSH_USER— SSH username for the upload.SSH_HOST— Hostname of the server receiving the files.REMOTE_PATH— Destination path on the remote server.
The script, step by step
Guard: check required variables are set
#!/bin/bash
# Check required environment variables
REQUIRED_VARS=("PLUGIN_NAME" "PLUGIN_ELEMENT" "PLUGIN_VERSION" "PLUGIN_DESCRIPTION" "PLUGIN_DIR" "OUTPUT_DIR" "UPDATE_SERVER" "SSH_USER" "SSH_HOST" "REMOTE_PATH")
for VAR in "${REQUIRED_VARS[@]}"; do
if [ -z "${!VAR}" ]; then
echo "Error: $VAR is not set."
exit 1
fi
done
The script exits immediately with a clear error message if any required variable is missing. This prevents a half-completed release caused by a missing value.
Remove macOS metadata files
# Remove .DS_Store files from current folder recursively
echo "Removing .DS_Store files from current folder recursively..."
find . -type f -name '*.DS_Store' -ls -delete
macOS Finder writes .DS_Store files into any directory it touches. They're harmless but untidy inside a plugin ZIP. This one-liner removes them recursively before packaging. If you're building on Linux or in a container this step does nothing, which is fine.
Prepare the output directory and define filenames
# Ensure output directory exists
mkdir -p "$OUTPUT_DIR"
# Define filenames
ZIP_FILE="${OUTPUT_DIR}/${PLUGIN_ELEMENT}-${PLUGIN_VERSION}.zip"
ZIP_FILE2="${OUTPUT_DIR}/${PLUGIN_ELEMENT}-latest.zip"
XML_FILE="${OUTPUT_DIR}/${PLUGIN_ELEMENT}.xml"
# Create zip archive
echo "Zipping plugin..."
cd "$PLUGIN_DIR"
zip -r "$ZIP_FILE" *
Two ZIP files are produced: a versioned file (e.g. autometa-1.1.28.zip) used by Joomla's update mechanism, and a -latest.zip used as a stable download link on the plugin's own page — so that link never needs updating.
Generate cryptographic hashes
# Generate hashes
SHA256=$(sha256sum "$ZIP_FILE" | awk '{print $1}')
SHA384=$(sha384sum "$ZIP_FILE" | awk '{print $1}')
SHA512=$(sha512sum "$ZIP_FILE" | awk '{print $1}')
Joomla's update system uses these hashes to verify the downloaded package is intact and hasn't been tampered with. Providing all three (SHA-256, SHA-384, SHA-512) is the current best practice for Joomla 5.x update manifests.
Generate the update XML manifest
# Generate update XML
echo "Generating update XML..."
cat > "$XML_FILE" <<EOL
<?xml version="1.0" encoding="utf-8"?>
<updates>
<update>
<name>${PLUGIN_NAME}</name>
<element>${PLUGIN_ELEMENT}</element>
<type>plugin</type>
<folder>content</folder>
<version>${PLUGIN_VERSION}</version>
<description>${PLUGIN_DESCRIPTION}</description>
<client>site</client>
<sha256>${SHA256}</sha256>
<sha384>${SHA384}</sha384>
<sha512>${SHA512}</sha512>
<downloads>
<downloadurl type="full">${UPDATE_SERVER}/${PLUGIN_ELEMENT}-${PLUGIN_VERSION}.zip</downloadurl>
</downloads>
<infourl>${UPDATE_SERVER}/updates/${PLUGIN_ELEMENT}-readme.html</infourl>
<targetplatform name="joomla" version="((4\.4)|(5\.(0|1|2|3|4|5|6|7|8|9)))"/>
<tags>
<tag>stable</tag>
</tags>
</update>
</updates>
EOL
The manifest is generated entirely from environment variables — nothing is hand-edited. The targetplatform regex covers Joomla 4.4 LTS and all Joomla 5.x minor releases. If you're supporting only Joomla 5.x going forward, you can simplify that regex accordingly. Generating this file programmatically rather than editing it by hand eliminates a whole category of copy-paste errors.
Upload to the update server
# Upload files to update server
echo "Uploading files to ${SSH_HOST}..."
scp "$ZIP_FILE" "$ZIP_FILE2" "$XML_FILE" "${SSH_USER}@${SSH_HOST}:${REMOTE_PATH}/"
echo "Build and deployment complete!"
SCP uploads the versioned ZIP, the latest ZIP, and the XML manifest to the update server in one step. SSH key authentication means no password prompt — the upload just happens. If you're moving toward a CI/CD pipeline in future, this scp call is easily replaced with an rsync, an S3 upload, or a GitHub Actions deployment step.
Example output
Here's what a successful run looks like:
% direnv allow .
direnv: loading .envrc
direnv: export +OUTPUT_DIR +PLUGIN_DESCRIPTION +PLUGIN_DIR +PLUGIN_ELEMENT +PLUGIN_NAME +PLUGIN_VERSION +REMOTE_PATH +SSH_HOST +SSH_USER +UPDATE_SERVER
% ./build_plg.sh
Removing .DS_Store files from current folder recursively...
Zipping plugin...
adding: autometa.php (deflated 53%)
adding: gpl-3.0.txt (deflated 66%)
adding: language/ (stored 0%)
adding: language/en-GB/ (stored 0%)
adding: language/en-GB/en-GB.plg_content_autometa.sys.ini (deflated 34%)
adding: plg-autometa.xml (deflated 47%)
Generating update XML...
Uploading files to multizone.co.uk...
autometa-1.1.28.zip 100% 14KB 250.9KB/s 00:00
autometa-latest.zip 100% 14KB 504.8KB/s 00:00
autometa.xml 100% 1126 67.5KB/s 00:00
Build and deployment complete!
It works — and here's the proof
After a day of testing and working through the Joomla update server requirements, the pipeline was solid. Joomla correctly detects the new version and the update installs cleanly.


Possible improvements for the future
The script works well as-is, but there are a few areas worth revisiting:
- Hardcoded plugin type. The XML currently assumes a site content plugin. Parameterising the
typeandfolderfields would make the script reusable across plugin groups (system, authentication, editors, and so on). - Version number duplication. The version string appears in the
.envrc, in the plugin's XML manifest, and potentially in the PHP file header. A single source of truth — perhaps reading the version from the plugin manifest — would reduce the risk of them drifting out of sync. - Changelog generation. Automatically appending a changelog entry on each release, drawn from Git commit messages since the last tag, would be a useful addition.
- GitHub Actions integration. For teams or public open-source plugins, moving the upload step into a GitHub Actions workflow triggered by a version tag would bring the process fully into a modern CI/CD model without changing the core logic.
- Joomla 6 readiness. Joomla 6 is on the roadmap. When it arrives, the
targetplatformregex will need updating — another reason to keep that value generated rather than hand-edited.
Next steps
With the update server pipeline in place, the next step is submitting the plugin to the Joomla Extensions Directory. You can read about the full submission process in From Start to Finish: How to Submit to the Joomla Extensions Directory Effectively.