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.

flowchart LR A[Push or pull request] --> B[Lint: PHPCS, PHPStan, ESLint] B --> C[Unit tests: PHPUnit] C --> D[Build assets and zips] D --> E[Install matrix: Joomla 5.4 and 6.x on Docker] E --> F{Tag vX.Y.Z?} F -- no --> G[Keep artefact only] F -- yes --> H[GitHub Release with the package zip] H --> I[Publish update XML to the update host]

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:

  1. Compute the version (from the tag in CI, from an argument locally) and write it into every manifest.

  2. Install the runtime Composer dependencies, if any, with --no-dev --optimize-autoloader.

  3. Compile the front-end assets into each extension’s media folder.

  4. Zip each child extension from inside its own folder.

  5. Zip the package: pkg_tallpine.xml, the install script and the folder with the child zips.

  6. Compute the checksum of the package zip.

  7. 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"

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 it constituents; the Tallpine package calls it packages. Either works, but the two must agree.

  • zip -r -X omits 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.

  • sha512sum is the GNU tool on Linux runners; macOS has shasum -a 512. A script used on both should detect which is available.

  • A git archive --format=zip of the tree with export-ignore rules in .gitattributes is a good alternative to zip because 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>

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

<version> in each manifest

The version stored in the extension’s manifest_cache after install.

The Extensions: Manage screen shows an old number; the update check offers the release again.

Highest SQL update file name (sql/updates/mysql/1.4.1.sql)

The row in #__schemas; the installer runs every SQL file newer than the stored value.

The database screen reports a mismatch, or a schema change never runs.

<version> in the package manifest

The version of the package as a whole.

The package cannot be offered as an update because its version did not rise.

<version> in the update XML entry and the zip file name

What Check for Updates compares with the installed version.

No update appears, or the download URL answers 404.

Git tag and changelog

Traceability and the changelogurl page.

The changelog button shows a different release than the one installed.

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";

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-cypress helper 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.json of 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-images container 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 with shivammathur/setup-php as 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.17 is for testing the install only. It is never published; only the tag job below produces a releasable version.

  • permissions: contents: read gives 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 host lets the Joomla container reach the MariaDB service on 127.0.0.1 and 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:list call 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.zip installs a file already in the container, and --url= installs from a URL (both options are defined by the ExtensionInstallCommand class 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.4 and one with postgres:16 and JOOMLA_DB_TYPE=pgsql if 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 --path with the new build and assert that the schema version row advanced, that data in #__tallpine_trails survived and that the new columns exist. An install-only test would not have caught a broken 1.4.1.sql.

  • Joomla 5.4 and Joomla 6 differ in more than PHP versions. The 6.0 release removed JPATH_PLATFORM, the CMS Input, BaseApplication and CliApplication classes and the legacy J* class aliases (they are available again only with the Backward Compatibility 6 plugin), and AdminModel::getItem() returns stdClass. 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 main and 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

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>

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/"

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 downloadurl must be a single line with no leading or trailing whitespace; a line break inside the element makes the update fail as malformed. A heredoc or sed template that wraps lines breaks this.

  • Provide sha256, sha384 or sha512. 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, rc and stable; 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.

  • targetplatform is 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; a collection serves 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_request from 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_modules or 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.lock and package-lock.json, run composer audit and npm audit in the lint job, and pin third-party actions to commit SHAs.

  • Least privilege. The workflow-level permissions are read-only; only the release job gets contents: 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 the folder attribute 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 zip was run from the parent directory. Run unzip -l in 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 the id of a <file> entry does not match the element in #__extensions (for a plugin the group is 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 targetplatform or php_minimum excludes the customer’s site.

  • CI fails on ls dist/pkg_tallpine-*.zip with several matches. The build wrote an older artefact into the same folder. Always delete dist first.

  • Matrix legs fail only on Joomla 6. A leftover JPATH_PLATFORM guard, JFactory, \Joomla\CMS\Input or getItem() returning CMSObject assumptions. Replace the guard by defined('_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.php in the container so that the namespace map is rebuilt.

  • latest moved. A matrix that uses the latest tag silently tests a different Joomla next month. Pin 6.2-phpX.Y-apache and 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.2 moving 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 the joomlaprojects/docker-images images 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.