Build and CI for Joomla Extensions
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
Joomla has no packager, no extension build tool and no hosted pipeline for third-party extensions. An extension is an ordinary zip file with an XML manifest in it, and everything that turns a Git repository into that zip, proves that it installs, and tells sites that a new release exists is the developer’s own automation. This page describes that automation for the Tallpine Hiking Club package pkg_tallpine: how the repository is laid out for building, the build script, how versions are kept consistent, a complete GitHub Actions pipeline (lint, test, package, publish the update XML) and a Docker-based matrix that installs the built package into Joomla 5.4 and Joomla 6.x on several PHP versions.
It was checked on 2026-10-30 against Joomla 6.2.x (the 6.2-dev branch of the source tree reports version 6.2.1-dev), PHP 8.3 or later, the Joomla Developer Manual, the Docker Official Image joomla (tags 6.2.0 and 5.4.10) and the build and CI files of the Joomla CMS repository itself. The Manual documents the manifest, the package layout and the update server format, but it has no page on build automation: the pipeline shapes below are therefore modelled on the CI of the Joomla CMS repository (.github/workflows/ci.yml, build/, tests/System/entrypoint.sh) and on common practice, and each example says which official page its parts derive from.
The pieces that this page assembles are explained in depth elsewhere and are linked rather than repeated: the manifest and package structure in Extension types, manifests and packaging, the install script and SQL files in Installer scripts and SQL updates, the update XML in Update servers and the JED, the tests in Testing extensions, the PHPCS and PHPStan setups in Coding standards and static analysis, and the local Docker environment in Local development with Docker.
The release pipeline at a glance
A release of pkg_tallpine goes through the same stages whether a person or a CI server runs them.
The first four stages run on every push and pull request. The install matrix is the stage that no amount of unit testing replaces, because it is the only one that exercises the real installer, the manifest, the SQL scripts and the namespace map. Only a version tag reaches the last two stages. Keeping the stages separate means that a failed lint check costs seconds, a failed install costs a couple of minutes and a publish only happens for something that has installed on every supported Joomla and PHP combination.
Repository layout for building
A layout that keeps the sources, the build output and the published files apart makes the build script short.
tallpine-joomla/
.github/workflows/ ci.yml, release.yml
build/ build.sh, build.xml (optional Phing), update.xml.dist, bump-version.php
src/
pkg_tallpine/ pkg_tallpine.xml, pkg_script.php, language/
com_tallpine/ tallpine.xml, administrator/, site/, api/, media/, services/
mod_tallpine_nexthikes/
plg_content_tallpine_conditions/
plg_webservices_tallpine/
plg_task_tallpine_trailsync/
plg_console_tallpine/
tests/
Unit/ PHPUnit tests for classes that do not need a database
System/ Cypress specs run against an installed site
composer.json dev tools: phpunit, phpcs, phpstan, joomla coding standards
package.json dev tools: eslint, stylelint, esbuild or sass
phpcs.xml phpstan.neon phpunit.xml.dist
dist/ generated, git-ignored
Two conventions matter. First, every extension folder is exactly what ends up inside its zip, with the manifest at the root of the folder, so zipping it needs no path manipulation. Second, generated files (dist/, compiled JavaScript and CSS under media, the vendor folder of any runtime library) are git-ignored and produced by the build, so a clean clone plus the build always gives the same zip. The development tools are Composer and npm dependencies of the repository, never of the shipped extension: the zip contains neither composer.json nor package.json nor tests.
Building the package
The build steps
Whatever tool drives it, the build does the same seven things in order:
-
Compute the version (from the tag in CI, from an argument locally) and write it into every manifest.
-
Install the runtime Composer dependencies, if any, with
--no-dev --optimize-autoloader. -
Compile the front-end assets into each extension’s
mediafolder. -
Zip each child extension from inside its own folder.
-
Zip the package:
pkg_tallpine.xml, the install script and the folder with the child zips. -
Compute the checksum of the package zip.
-
Write the update XML entry for this release.
The core of Joomla follows the same idea. Its own package.json exposes npm run build (a Node script, build/build.mjs, that copies vendor files and compiles media_source into media), npm run lint:js and npm run lint:css, and its composer.json exposes composer phpcs and composer csfixer; the release scripts build/bump.php and build/build.php set versions and produce the release archives. Core’s own scripts are specific to core and are not a reusable packager, but they show the vocabulary: one command per concern, each runnable locally and in CI.
A plain shell script
For a package of this size a shell script is enough, and it is the form most Joomla extension authors use. It extends the short example from Extension types, manifests and packaging with a version argument, a clean working directory, reproducible archives and a checksum file.
#!/usr/bin/env bash
# build/build.sh <version> e.g. build/build.sh 1.4.1
set -euo pipefail
VERSION="${1:?usage: build.sh <version>}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist"
STAGE="$OUT/stage"
rm -rf "$OUT" && mkdir -p "$STAGE/packages"
# 1. Write the version into every manifest (see "Versioning" below).
php "$ROOT/build/bump-version.php" "$VERSION"
# 2. Runtime libraries for the component only; dev tools stay out of the zip.
if [ -f "$ROOT/src/com_tallpine/composer.json" ]; then
composer install --working-dir="$ROOT/src/com_tallpine" --no-dev --optimize-autoloader --no-interaction
fi
# 3. Front-end assets, compiled into media/ of each extension.
npm ci --prefix "$ROOT"
npm run --prefix "$ROOT" build
# 4. Child zips: run zip INSIDE the folder so the manifest is at the archive root.
zip_ext () {
local dir="$1" name="$2"
(cd "$ROOT/src/$dir" && zip -r -X -q "$STAGE/packages/$name.zip" . \
-x '*.DS_Store' -x 'tests/*' -x 'composer.json' -x 'composer.lock' -x 'package*.json' -x '*.map')
}
zip_ext com_tallpine com_tallpine
zip_ext mod_tallpine_nexthikes mod_tallpine_nexthikes
for plugin in plg_content_tallpine_conditions plg_webservices_tallpine plg_task_tallpine_trailsync plg_console_tallpine; do
zip_ext "$plugin" "$plugin"
done
# 5. The package zip: manifest, script and the folder named in <files folder="...">.
cp "$ROOT/src/pkg_tallpine/pkg_tallpine.xml" "$ROOT/src/pkg_tallpine/pkg_script.php" "$STAGE/"
cp -R "$ROOT/src/pkg_tallpine/language" "$STAGE/language"
(cd "$STAGE" && zip -r -X -q "$OUT/pkg_tallpine-$VERSION.zip" pkg_tallpine.xml pkg_script.php language packages)
# 6. Checksum, in the form the update XML needs.
(cd "$OUT" && sha512sum "pkg_tallpine-$VERSION.zip" | cut -d' ' -f1 > "pkg_tallpine-$VERSION.sha512")
echo "Built $OUT/pkg_tallpine-$VERSION.zip"
Source: Joomla Manual: Packages
Points that cause trouble in practice:
-
The folder named in the package manifest
<files folder="...">must match the folder inside the zip. The Manual’s example calls itconstituents; the Tallpine package calls itpackages. Either works, but the two must agree. -
zip -r -Xomits extra file attributes, which keeps archives free of local user ids. Timestamps still differ between runs, so the checksum of two builds of the same commit differs: compute the checksum once, on the file that is published, never on a rebuilt copy. -
sha512sumis the GNU tool on Linux runners; macOS hasshasum -a 512. A script used on both should detect which is available. -
A
git archive --format=zipof the tree withexport-ignorerules in.gitattributesis a good alternative tozipbecause it cannot include untracked files; the cost is that generated assets must be committed or added afterwards. -
Language folders referenced by the manifest (
<languages>) must be present in the child zip. A missing language file is a silent defect: the installer does not fail, but the interface shows raw language keys.
Composer and npm in the build
Composer plays two separate roles that must not be confused. At development time it installs PHPUnit, PHP_CodeSniffer, PHPStan and the Joomla stubs into the repository’s vendor. At build time it can install runtime libraries (a GPX parser for the trail files, say) into the extension’s own folder. The first must never be shipped; the second is the only case in which vendor goes into a zip, and it is installed with --no-dev --optimize-autoloader, listed in the manifest <files> and, if a second extension might ship a different release of the same library, namespace-prefixed with a tool such as PHP-Scoper or Strauss. Composer itself is introduced in Composer and packages.
npm similarly has two roles. devDependencies run ESLint, Stylelint and the bundler; the shipped result is compiled JavaScript and CSS under media/com_tallpine, registered through joomla.asset.json (see Web Asset Manager). Core’s own build requires Node 20 or later and npm 10.1 or later (the engines field of its package.json), and extension projects typically follow the same floor. Commit package-lock.json and use npm ci in CI so that two runs install the same tree.
Phing and other build tools
Phing is a PHP build tool modelled on Apache Ant, with targets described in an XML build.xml; it is popular in the Joomla ecosystem for packaging because it is installed with Composer and needs no Node. The same package build in Phing, trimmed to its essentials:
<?xml version="1.0" encoding="UTF-8"?>
<project name="tallpine" default="package" basedir=".">
<property name="version" value="0.0.0-dev"/>
<property name="out" value="${project.basedir}/dist"/>
<target name="clean">
<delete dir="${out}"/>
<mkdir dir="${out}/stage/packages"/>
</target>
<target name="child-zips" depends="clean">
<zip destfile="${out}/stage/packages/com_tallpine.zip" basedir="src/com_tallpine"/>
<zip destfile="${out}/stage/packages/mod_tallpine_nexthikes.zip" basedir="src/mod_tallpine_nexthikes"/>
</target>
<target name="package" depends="child-zips">
<copy todir="${out}/stage">
<fileset dir="src/pkg_tallpine"/>
</copy>
<zip destfile="${out}/pkg_tallpine-${version}.zip" basedir="${out}/stage"/>
</target>
</project>
Source: Phing user guide
It is run as vendor/bin/phing -Dversion=1.4.1 package. Make, Gulp, Robo, Composer scripts (a scripts entry named build that runs bash build/build.sh) and a plain shell script are all equally valid. The deciding factors are the team’s tools and portability, not Joomla: the installer sees only the resulting zip. Whatever is chosen, keep a single entry point that CI and developers both call, so that "works on my machine" and "works in CI" describe the same code.
Versioning
A release number appears in at least five places, and a mismatch between them produces confusing symptoms.
| Where | What it controls | Symptom when it is wrong |
|---|---|---|
|
The version stored in the extension’s |
The Extensions: Manage screen shows an old number; the update check offers the release again. |
Highest SQL update file name ( |
The row in |
The database screen reports a mismatch, or a schema change never runs. |
|
The version of the package as a whole. |
The package cannot be offered as an update because its version did not rise. |
|
What |
No update appears, or the download URL answers 404. |
Git tag and changelog |
Traceability and the |
The changelog button shows a different release than the one installed. |
Source: Joomla Manual: Manifest files
The robust rule is one source of truth: the Git tag. The pipeline reads v1.4.1 from GITHUB_REF_NAME, strips the v and writes the number into every manifest before packaging, so no human edits version strings by hand. Joomla compares versions with PHP’s version_compare() (see Update servers and the JED), so 1.4.10 is newer than 1.4.9 and pre-release suffixes should follow a PHP-compatible spelling such as 1.5.0-rc1. Keep to Semantic Versioning: a patch release changes no behaviour, a minor release adds features and, when com_tallpine ships SQL changes, adds an update file, and a major release is the only one allowed to drop support for a Joomla series.
A small PHP script that writes the version into every manifest keeps the logic in the language the project already uses:
<?php
// build/bump-version.php <version>
declare(strict_types=1);
$version = $argv[1] ?? null;
if ($version === null || !preg_match('/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/', $version)) {
fwrite(STDERR, "usage: bump-version.php <semver>\n");
exit(1);
}
$root = dirname(__DIR__) . '/src';
$manifests = [
'pkg_tallpine/pkg_tallpine.xml',
'com_tallpine/tallpine.xml',
'mod_tallpine_nexthikes/mod_tallpine_nexthikes.xml',
'plg_content_tallpine_conditions/conditions.xml',
'plg_webservices_tallpine/tallpine.xml',
'plg_task_tallpine_trailsync/trailsync.xml',
'plg_console_tallpine/tallpine.xml',
];
foreach ($manifests as $relative) {
$path = "$root/$relative";
$xml = file_get_contents($path);
$updated = preg_replace('#<version>[^<]*</version>#', "<version>$version</version>", $xml, 1, $count);
if ($count !== 1) {
fwrite(STDERR, "No <version> element in $relative\n");
exit(1);
}
$updated = preg_replace('#<creationDate>[^<]*</creationDate>#', '<creationDate>' . date('Y-m') . '</creationDate>', $updated, 1);
file_put_contents($path, $updated);
}
echo "Manifests set to $version\n";
Source: Joomla Manual: Manifest files
Joomla’s own release tooling does the same job for the core: php build/bump.php -v 6.2.1 (options -c for the codename and -d for the release date) rewrites the version constants in libraries/src/Version.php and every core manifest. Run on the CI checkout, the script changes files only in the working copy; nothing is committed back, so the tag and the repository stay the record of truth and the built zip carries the stamped numbers.
Two version-related points belong in the pipeline as checks rather than conventions. Fail the release job if the SQL update directory has no file for the new version while the changelog mentions a schema change, and fail it if the tag is not greater than the previous tag (sort -V). Both are one-line shell tests and prevent the classic "released but nobody can update" mistake.
Linting and testing in the pipeline
The quality gates are the same tools described in Coding standards and static analysis and Testing extensions; this section only wires them into CI. The Joomla CMS repository runs PHP code style with PHP_CodeSniffer and PHP-CS-Fixer (composer phpcs, composer csfixer), static analysis with PHPStan, JavaScript and CSS style with ESLint and Stylelint (npm run lint:js, npm run lint:css), unit and integration tests with PHPUnit and system tests with Cypress. Since Joomla 4.2 the PHP coding style is PSR-12 based, and the core system tests use Cypress, not Codeception, which older books still describe.
An extension needs the subset that fits it:
-
PHPCS with the PSR-12 standard plus the Joomla-specific sniffs the project chooses, over
src/. -
PHPStan with the Joomla stubs and a baseline file, so that existing findings are tolerated but new ones fail the build.
-
PHPUnit for classes that need neither the database nor the application: helpers, the SEF router rules, the update XML generator, value objects.
-
ESLint and Stylelint for the sources that the build compiles into
media. -
Cypress system tests (using the
joomla-cypresshelper package) against an installed Joomla, for flows that cross layers: installing the package, creating a trail in the administrator, seeing it on the site.
A GitHub Actions workflow for every push
The following workflow lints, tests, builds and then runs the install matrix of the next section. General workflow syntax, caching and secrets are covered in CI/CD with GitHub Actions; here only the Joomla-specific parts are explained.
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint-and-unit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
coverage: none
- uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-progress --prefer-dist
- run: vendor/bin/phpcs --standard=phpcs.xml src
- run: vendor/bin/phpstan analyse --no-progress
- run: vendor/bin/phpunit --testsuite Unit
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run lint:js && npm run lint:css
build:
needs: lint-and-unit
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Build the package
run: bash build/build.sh "0.0.0-ci.${GITHUB_RUN_NUMBER}"
- uses: actions/upload-artifact@v4
with:
name: pkg_tallpine
path: dist/pkg_tallpine-*.zip
if-no-files-found: error
Notes:
-
Joomla 6 requires PHP 8.3 or later (the
composer.jsonof the 6.2 source tree requires^8.3.0), so PHP 8.3 is the lowest version used for lint and unit jobs. If the extension also supports Joomla 5.4, which runs on PHP 8.2, add 8.2 to the lint job’s matrix and keep the code free of 8.3-only syntax. -
The CI of the Joomla CMS repository runs inside the
joomlaprojects/docker-imagescontainer images, which bundle PHP, Composer and the tooling the project needs. Extension projects can use those images as job containers as well, or install PHP withshivammathur/setup-phpas above. Pin the image or action to a version and review updates, as the pipeline runs with repository access. -
A build with a version such as
0.0.0-ci.17is for testing the install only. It is never published; only the tag job below produces a releasable version. -
permissions: contents: readgives the default token the minimum rights. Jobs that publish raise their own permissions explicitly. -
Actions in the examples are referenced by major-version tag for readability. For a production pipeline pin third-party actions to a commit SHA, as the Joomla CMS repository does for its own workflow.
Docker-based install matrix for Joomla 5.4 and 6.x
Unit tests run on the host; installation is tested in real Joomla containers. The Docker Official Image joomla publishes, at the time of checking, the tags 6.2.0-php8.3-apache, 6.2.0-php8.4-apache and 6.2.0-php8.5-apache (and the matching fpm and fpm-alpine variants, with the moving tags 6.2, 6 and latest) plus 5.4.10-php8.2-apache and 5.4.10-php8.3-apache (moving tags 5.4 and 5). The matrix therefore has to be written from the real combinations, not as a full cross product: Joomla 5.4 does not exist on PHP 8.4, and Joomla 6 does not exist on PHP 8.2.
The image reads its configuration from environment variables: JOOMLA_DB_HOST, JOOMLA_DB_USER, JOOMLA_DB_PASSWORD, JOOMLA_DB_NAME and JOOMLA_DB_TYPE (mysqli or pgsql) for the database, and JOOMLA_SITE_NAME, JOOMLA_ADMIN_USER, JOOMLA_ADMIN_USERNAME, JOOMLA_ADMIN_PASSWORD, JOOMLA_ADMIN_EMAIL to install without the browser. JOOMLA_EXTENSIONS_PATHS and JOOMLA_EXTENSIONS_URLS take semicolon-separated lists of files or URLs and install those extensions during that automatic installation, which is exactly what a package test needs. Local use of the same variables is shown in Local development with Docker, and image tagging and registries in Registries and Docker Hub.
# continued in .github/workflows/ci.yml
install-matrix:
needs: build
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- { joomla: '5.4', php: '8.2', tag: '5.4-php8.2-apache' }
- { joomla: '5.4', php: '8.3', tag: '5.4-php8.3-apache' }
- { joomla: '6.2', php: '8.3', tag: '6.2-php8.3-apache' }
- { joomla: '6.2', php: '8.4', tag: '6.2-php8.4-apache' }
- { joomla: '6.2', php: '8.5', tag: '6.2-php8.5-apache' }
name: Joomla ${{ matrix.joomla }} on PHP ${{ matrix.php }}
services:
db:
image: mariadb:11.4
env:
MARIADB_ROOT_PASSWORD: ${{ github.run_id }}-root
MARIADB_DATABASE: joomla
ports: ['3306:3306']
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s --health-timeout=5s --health-retries=20
steps:
- uses: actions/download-artifact@v4
with:
name: pkg_tallpine
path: dist
- name: Start Joomla and install the package
run: |
ADMIN_PASSWORD="$(openssl rand -hex 16)"
echo "::add-mask::$ADMIN_PASSWORD"
echo "ADMIN_PASSWORD=$ADMIN_PASSWORD" >> "$GITHUB_ENV"
PKG="$(ls "$PWD"/dist/pkg_tallpine-*.zip)"
docker run -d --name joomla --network host \
-v "$PKG:/tmp/pkg_tallpine.zip:ro" \
-e JOOMLA_DB_HOST=127.0.0.1:3306 \
-e JOOMLA_DB_USER=root \
-e JOOMLA_DB_PASSWORD="${{ github.run_id }}-root" \
-e JOOMLA_DB_NAME=joomla \
-e JOOMLA_SITE_NAME="Tallpine CI" \
-e JOOMLA_ADMIN_USER="CI Admin" \
-e JOOMLA_ADMIN_USERNAME=ciadmin \
-e JOOMLA_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
-e JOOMLA_ADMIN_EMAIL=ci@tallpine.example \
-e JOOMLA_EXTENSIONS_PATHS=/tmp/pkg_tallpine.zip \
joomla:${{ matrix.tag }}
- name: Wait for the site
run: |
for i in $(seq 1 60); do
curl -fsS -o /dev/null http://127.0.0.1/ && exit 0
sleep 5
done
docker logs joomla
exit 1
- name: Verify the install
run: |
docker exec -u www-data joomla php cli/joomla.php extension:list | grep -E 'pkg_tallpine|com_tallpine'
curl -fsS http://127.0.0.1/index.php?option=com_tallpine | grep -qi tallpine
- if: failure()
run: docker logs joomla
How this works and what to watch for:
-
The database password for the throwaway MariaDB service is derived from the run id, and the administrator password is generated for each run and masked in the log. No secret value is written in the workflow; for a pipeline that needs a real credential (the deploy key below), GitHub’s encrypted secrets are used with
${{ secrets.NAME }}references only. -
--network hostlets the Joomla container reach the MariaDB service on127.0.0.1and puts Apache on port 80 of the runner, which keeps the example short. A Compose file with a private network, as in Compose for local development, is the more portable alternative. -
The automatic installation runs once, at first start, when the site variables are set; the extension list is processed as part of it. The
extension:listcall proves that Joomla itself, not only the file copy, registered the package and its children. If the installation needs a second extension afterwards,docker exec -u www-data joomla php cli/joomla.php extension:install --path=/tmp/other.zipinstalls a file already in the container, and--url=installs from a URL (both options are defined by theExtensionInstallCommandclass of the 6.2 source). Always run the CLI as the web server user, so that the files it creates are not owned by root. -
MySQL and PostgreSQL are separate code paths. The matrix above uses MariaDB because it is Joomla’s most common database; add a leg with
mysql:8.4and one withpostgres:16andJOOMLA_DB_TYPE=pgsqlif the extension ships SQL for each. The CI of the Joomla CMS repository itself runs its integration and system tests against MySQL, MariaDB and PostgreSQL service containers. -
Test the upgrade path too: install the previous release (the artefact of the last tag), then run
extension:install --pathwith the new build and assert that the schema version row advanced, that data in#__tallpine_trailssurvived and that the new columns exist. An install-only test would not have caught a broken1.4.1.sql. -
Joomla 5.4 and Joomla 6 differ in more than PHP versions. The 6.0 release removed
JPATH_PLATFORM, the CMSInput,BaseApplicationandCliApplicationclasses and the legacyJ*class aliases (they are available again only with the Backward Compatibility 6 plugin), andAdminModel::getItem()returnsstdClass. An extension that must run on both lines is exactly what this matrix finds. See Upgrading Joomla 5 to 6 and Deprecations and preparing for Joomla 7. -
Matrices multiply cost. A practical compromise is to run the full matrix on pull requests to
mainand on tags, and only the newest combination on feature branches.
System tests in the same containers
For Cypress specs the Joomla CMS repository ships tests/System/entrypoint.sh, an entry point for the joomlaprojects/docker-images:cypress family of images: it copies the working tree to a test web root, starts Apache, installs Cypress if needed and runs cypress run with the database engine, host, password and prefix passed as --expose values. An extension can reuse the idea without the core entry point: install Joomla from the official image as above, then run the specs from the repository with npx cypress run --config baseUrl=http://127.0.0.1 and the joomla-cypress package for the login and installer helpers. Turn on screenshots and upload them as artefacts when a spec fails.
Releasing on a tag
The release workflow runs when a tag matching v* is pushed. It rebuilds from the tag, publishes the zip as a GitHub Release asset and regenerates the update XML on the update host.
# .github/workflows/release.yml
name: release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Build
run: bash build/build.sh "${GITHUB_REF_NAME#v}"
- name: Create the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/pkg_tallpine-*.zip \
--title "Tallpine $GITHUB_REF_NAME" --generate-notes
- name: Generate the update entry
env:
VERSION: ${{ github.ref_name }}
run: |
V="${VERSION#v}"
SUM="$(cat "dist/pkg_tallpine-$V.sha512")"
mkdir -p site/releases
sed -e "s/@VERSION@/$V/g" -e "s/@SHA512@/$SUM/g" build/update.xml.dist > "site/releases/$V.xml"
- name: Publish update host
env:
DEPLOY_KEY: ${{ secrets.UPDATE_HOST_DEPLOY_KEY }}
DEPLOY_TARGET: ${{ secrets.UPDATE_HOST_TARGET }}
run: bash build/publish-update-xml.sh
Source: Joomla Manual: Update Servers
The entry template, build/update.xml.dist, is one update element with the placeholders @VERSION@ and @SHA512@:
<update>
<name>Tallpine Hiking Club package</name>
<description>Trail reports, conditions and next-hikes module.</description>
<element>pkg_tallpine</element>
<type>package</type>
<version>@VERSION@</version>
<infourl title="Tallpine @VERSION@">https://updates.tallpine.example/releases/@VERSION@.html</infourl>
<changelogurl>https://updates.tallpine.example/changelog.xml</changelogurl>
<downloads>
<downloadurl type="full" format="zip">https://updates.tallpine.example/dl/pkg_tallpine-@VERSION@.zip</downloadurl>
</downloads>
<tags><tag>stable</tag></tags>
<maintainer>Tallpine web team</maintainer>
<maintainerurl>https://tallpine.example</maintainerurl>
<targetplatform name="joomla" version="(5\.[4-9]|6\.[0-9]+)"/>
<php_minimum>8.3</php_minimum>
<sha512>@SHA512@</sha512>
</update>
Source: Joomla Manual: Update Servers
The publishing script joins every fragment in site/releases under one <updates> root and uploads the result together with the zip. Appending rather than overwriting is essential: a site on Joomla 5.4 must keep seeing the release that matches its series even after a newer one that requires Joomla 6 appears.
#!/usr/bin/env bash
# build/publish-update-xml.sh -- DEPLOY_KEY and DEPLOY_TARGET come from CI secrets
set -euo pipefail
KEYFILE="$(mktemp)"; trap 'rm -f "$KEYFILE"' EXIT
printf '%s\n' "$DEPLOY_KEY" > "$KEYFILE"; chmod 600 "$KEYFILE"
mkdir -p site/dl
cp dist/pkg_tallpine-*.zip site/dl/
# Fetch the existing fragments so older releases stay in the file.
rsync -a -e "ssh -i $KEYFILE -o StrictHostKeyChecking=yes" "$DEPLOY_TARGET/releases/" site/releases/ || true
{
echo '<?xml version="1.0" encoding="utf-8"?>'
echo '<updates>'
cat $(ls site/releases/*.xml | sort -V)
echo '</updates>'
} > site/pkg_tallpine.xml
xmllint --noout site/pkg_tallpine.xml
rsync -a -e "ssh -i $KEYFILE -o StrictHostKeyChecking=yes" site/ "$DEPLOY_TARGET/"
Source: Joomla Manual: Update Servers
The script writes the DEPLOY_KEY secret to a temporary file with mode 600 and removes it on exit; restrict the key on the server to the update directory, and never print it. The same job can instead publish to GitHub Pages with the actions/upload-pages-artifact and actions/deploy-pages actions, which needs no SSH key at all and gives the update XML a stable HTTPS URL.
Details of the update XML that pipelines most often get wrong:
-
The
downloadurlmust be a single line with no leading or trailing whitespace; a line break inside the element makes the update fail as malformed. A heredoc orsedtemplate that wraps lines breaks this. -
Provide
sha256,sha384orsha512. If a checksum is present and does not match the downloaded file, the update stops, so the hash must be taken from the exact file that is published (the build writes it next to the zip for that reason). -
Valid stability tags are
dev,alpha,beta,rcandstable; if several are given only the last one counts and no tag means stable. Pre-release tags let a pipeline publish release candidates to a separate update file that only testers subscribe to. -
targetplatformis a regular expression anchored at the start; escape dots and review it at every Joomla minor release. An entry that stops at version 5.0, as in older books, hides the update from Joomla 5.1 and later and from Joomla 6. -
If the zip is served from GitHub Releases, the download URL redirects to a storage host. Joomla’s HTTP client follows redirects, but test the path with a real update on a clean site rather than assuming it.
-
A server of
type="extension"serves one extension’s file; acollectionserves several. Use a collection only when one URL lists many extensions.
Extension updates also carry a security flag since Joomla 6.2: the security element of the update entry marks a release as a security release so that sites can highlight it. Set it from a release label or a command-line flag in the pipeline instead of editing the template by hand. See Update servers and the JED for the semantics and for download keys on paid extensions, which the update host, not the pipeline, must check.
Security of the pipeline
-
Secrets stay in the secret store. Deploy keys, download-key signing material and JED credentials are repository or environment secrets. They are never echoed, never written into the zip and never placed in workflow files. A workflow that runs on
pull_requestfrom forks must not receive them: GitHub withholds secrets from fork builds by default, and publishing jobs should additionally be limited to tags and a protected environment with required reviewers. -
The zip is public. The build must not copy
.env,configuration.php, test fixtures with real data,.git,node_modulesor IDE folders. Listing the allowed folders (a whitelist) is safer than excluding bad ones, and a final CI step that unpacks the zip and fails if a forbidden file name appears is cheap insurance. -
Pin and review dependencies. Commit
composer.lockandpackage-lock.json, runcomposer auditandnpm auditin the lint job, and pin third-party actions to commit SHAs. -
Least privilege. The workflow-level
permissionsare read-only; only the release job getscontents: write. -
Integrity for the user. The update XML checksum protects the download between the host and the site. Core updates are verified with The Update Framework (TUF) since Joomla 5.1; extension updates are not covered by it, so the checksum, HTTPS and an access-controlled update host are the extension’s integrity chain.
-
Reproducibility. Build on a clean runner from a tag, not from a developer’s folder, so that every published zip can be traced to a commit.
Common errors and pitfalls
-
The package installs but a child extension is missing. The
<file>entry names a zip that is not in the folder, or thefolderattribute of<files>differs from the folder in the zip. The installer reports success for the entries it could process. -
"Unable to find install package" in CI only. The zip was created with a wrapping folder that also contains other files, or
zipwas run from the parent directory. Rununzip -lin the build and assert that the manifest is at the root. -
Installing the same version again does nothing. A package with the same version is treated as an update; with
method="upgrade"the files are overwritten, but SQL update files only run for newer versions. Bump the version for every build that must exercise the SQL path. -
Uninstall fails for the package. The package manifest file is not named
pkg_<packagename>.xml, or theidof a<file>entry does not match the element in#__extensions(for a plugin thegroupis also required). -
Update check works locally but not for customers. The update server URL is HTTP-only behind a redirect, the host blocks the default user agent, or
targetplatformorphp_minimumexcludes the customer’s site. -
CI fails on
ls dist/pkg_tallpine-*.zipwith several matches. The build wrote an older artefact into the same folder. Always deletedistfirst. -
Matrix legs fail only on Joomla 6. A leftover
JPATH_PLATFORMguard,JFactory,\Joomla\CMS\InputorgetItem()returningCMSObjectassumptions. Replace the guard bydefined('_JEXEC') or die;and the aliases by namespaced classes, or require the BC6 plugin explicitly in the documentation. -
Cached autoload map. If a test changes the extension list without the installer, delete
administrator/cache/autoload_psr4.phpin the container so that the namespace map is rebuilt. -
latestmoved. A matrix that uses thelatesttag silently tests a different Joomla next month. Pin6.2-phpX.Y-apacheand update the pin deliberately.
Version notes
-
Joomla 6.2 is the current line and 5.4 is security-only (5.4.10 was its last bugfix release, with security fixes until 2027-10-12), so a new extension release should be tested on both unless it deliberately targets 6.x only. Joomla 4.x reached end of life in October 2025 and 3.x earlier; do not add them to a new matrix.
-
PHP 8.3 is the minimum for Joomla 6; the official image offers 6.2 on PHP 8.3, 8.4 and 8.5 and 5.4 on PHP 8.2 and 8.3.
-
Minimum requirements rise only in the first release of a major, and code deprecated in one major is removed in the second one after it. A pipeline should therefore treat deprecation notices as build warnings today and as failures when the next major is added to the matrix.
-
Automated Core Updates (5.4 and 6.0) change what a staging pipeline must expect: a production site may have moved to a newer patch before the extension was tested against it. Include the
6.2moving tag, not only the exact patch, in at least one matrix leg. -
The Joomla Manual has no page on build tooling; the Joomla CMS repository’s workflows,
build/scripts and thejoomlaprojects/docker-imagesimages are the nearest official reference and may change without notice.
Corrections to older material
Older books such as Developing Extensions for Joomla! 5 (2023) cover packaging and update servers with download keys but no continuous integration and no Docker; their update XML examples target a platform expression that excludes Joomla 5.1 and later and Joomla 6, and they describe Codeception as the system-test tool of the core, which has been replaced by Cypress. Joomla! 4 Masterclass (2023) targets Joomla 4, which is end of life, and does not treat building or testing. Tutorials that start from joomla:3.9-php7.4-apache or joomla:4 images cannot be used for a current matrix: Joomla 6 needs PHP 8.3 or later and the image names have changed with the php8.x-apache tag structure.