Reusable GitHub Actions workflows for Agileware's CiviCRM extension and WordPress plugin repos. Most workflows spin up a WordPress + CiviCRM environment in Docker and run a specific task against it; the release workflows operate on git/GitHub directly instead.
- civix Upgrade Workflow β runs
civix upgradeand opens a PR with the result - CiviCRM PHPUnit Testing Workflow β runs an extension's headless PHPUnit suite
- Playwright Frontend Testing Workflow β runs an extension's Playwright test suite
- WordPress Plugin Cut Release Workflow β builds a filtered release branch, tags it, and publishes the GitHub Release
- CiviCRM Extension Cut Release Workflow β same, versioned from info.xml instead of a plugin header
- CiviCRM Extension Init Release Workflow β one-time setup that adds the Cut Release workflow and
releasebranch to a CiviCRM extension repo
This reusable GitHub Actions workflow sets up a WordPress + CiviCRM environment and runs civix upgrade on a CiviCRM extension, then opens a pull request with the regenerated files.
.github/workflows/civix-upgrade.yml
To use this workflow in another repo, add a workflow such as .github/workflows/upgrade.yml:
name: Upgrade Extension
on:
push
jobs:
upgrade:
uses: agileware/ci-workflows/.github/workflows/civix-upgrade.yml@main
secrets:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
# Optional
# with:
# EXTENSION_NAME: my_custom_extensionDOCKERHUB_USERDOCKERHUB_TOKEN
- If
EXTENSION_NAMEis not provided, the workflow defaults to the name of the calling repository. - Changes are committed and pushed to a
civix-upgradebranch, and a pull request is opened against the repository's default branch. - Docker logs are collected and uploaded as the
logs.tgzartifact if the workflow fails.
This reusable GitHub Actions workflow sets up a WordPress + CiviCRM environment and runs an extension's PHPUnit test suite (CiviCRM's headless test framework, PHPUnit 9).
.github/workflows/civicrm-phpunit-tests.yml
To use this workflow in another repo, add a workflow such as .github/workflows/phpunit.yml:
name: PHPUnit Tests
on:
push
workflow_dispatch:
jobs:
phpunit:
uses: agileware/ci-workflows/.github/workflows/civicrm-phpunit-tests.yml@main
secrets:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
# Optional
# with:
# EXTENSION_NAME: my_custom_extension
# PHPUNIT_ARGS: tests/phpunit/CRM/Foo/BarTest.phpThe extension must provide a phpunit.xml.dist (bootstrapping tests/phpunit/bootstrap.php) and tests written against Civi\Test's HeadlessInterface, per CiviCRM's PHPUnit testing docs.
DOCKERHUB_USERDOCKERHUB_TOKEN
- If
EXTENSION_NAMEis not provided, the workflow defaults to the name of the calling repository. PHPUNIT_ARGSis passed straight through tophpunit9, e.g. to target a single test file. It defaults to running the full suite from the extension'sphpunit.xml.dist.phpunit9is downloaded as an official phar if the base image doesn't already provide it.- The headless test database is a clone of the main site database (connected to as
root, sinceCivi\Test's schema install needsSUPER), never the main site DB itself. - JUnit results are always uploaded as the
phpunit-resultsartifact. - Docker logs are collected and uploaded as the
logs.tgzartifact if the workflow fails.
This reusable GitHub Actions workflow sets up a WordPress + CiviCRM environment, enables the extension under test, and runs its Playwright end-to-end test suite.
.github/workflows/playwright-tests.yml
To use this workflow in another repo, add a workflow such as .github/workflows/playwright.yml:
name: Playwright Tests
on:
push
workflow_dispatch:
jobs:
playwright:
uses: agileware/ci-workflows/.github/workflows/playwright-tests.yml@main
secrets:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
# Optional
# with:
# EXTENSION_NAME: my_custom_extension
# COMPONENT_TYPE: extension
# EXTENSION_KEY: my_custom_extension
# PLAYWRIGHT_DIR: tests/playwright
# SETUP_SCRIPT: tests/playwright/setup.sh
# NODE_VERSION: '20'The component must provide a package.json and playwright.config.ts (by default under tests/playwright), with tests written to run against a live WordPress + CiviCRM site.
The same workflow tests a WordPress plugin by setting COMPONENT_TYPE: plugin. The repo is then mounted under wp-content/plugins and activated with wp plugin activate instead of cv ext:enable:
with:
EXTENSION_NAME: wp-civicrm-ux
COMPONENT_TYPE: plugin
SETUP_SCRIPT: tests/playwright/fixtures/setup-environment.shEXTENSION_NAMEβ name of the component under test, and the folder it's mounted under. Defaults to the calling repository's name.COMPONENT_TYPEβextension(default) orplugin. Decides where the repo is mounted and how it is installed: a CiviCRM extension goes under the CiviCRM extensions directory and is enabled withcv ext:enable; a WordPress plugin goes underwp-content/pluginsand is activated withwp plugin activate. The default leaves existing callers unchanged.EXTENSION_KEYβ CiviCRM extension key used to enable it viacv ext:enable. Defaults toEXTENSION_NAME. Ignored whenCOMPONENT_TYPEisplugin.PLAYWRIGHT_DIRβ directory (relative to the repo root) containingpackage.jsonandplaywright.config.ts. Defaults totests/playwright.SETUP_SCRIPTβ optional path (relative to the repo root) to a repo-specific shell script that runs after WordPress/CiviCRM/the component are up, before the Playwright tests. Use it to create test users/roles and seed data. Runs aswww-datawith its working directory set to the component's folder inside the WordPress container, so it can callwpandcvdirectly.NODE_VERSIONβ Node.js version to run Playwright with. Defaults to20.PRE_ACTIVATE_SCRIPTβ optional path (relative to the repo root) to a repo-specific shell script that runs after WordPress and CiviCRM are installed but before the component is activated or enabled. Use it to install plugins the component depends on: since WordPress 6.5,wp plugin activaterefuses a plugin whoseRequires plugins:are not active. Runs aswww-datain the component's folder, likeSETUP_SCRIPT, and receives theGRAVITYFORMS_LICENSE_KEYsecret (if passed) as an environment variable.
DOCKERHUB_USERDOCKERHUB_TOKEN
Optional:
GRAVITYFORMS_LICENSE_KEYβ for callers whosePRE_ACTIVATE_SCRIPTinstalls Gravity Forms or its add-ons, which are commercially licensed. Passed to the script as an environment variable and never placed on a command line. Pull requests from forks do not receive secrets.
- The Astra theme is installed and activated, and WordPress permalinks are set to
/%postname%/, so that CiviCRM's clean URLs (e.g./civicrm/dashboard) resolve as tests expect. - A CiviCRM WordPress base page (slug
civicrm) is created and set as thewpBasePagesetting before rewrite rules are flushed, so CiviCRM's own rewrite rule is included. - Tests run via
npx playwright testfromPLAYWRIGHT_DIR, withBASE_URL,WP_ADMIN_USER,WP_ADMIN_PASSandCIVI_EXEC_PREFIX(a ready-to-usedocker execprefix for runningwp/cvinside the extension folder) available as environment variables. - The Playwright HTML report is always uploaded as the
playwright-reportartifact; on failure, screenshots/traces are also uploaded as theplaywright-test-resultsartifact. - Docker logs are collected and uploaded as the
logs.tgzartifact if the workflow fails.
This reusable GitHub Actions workflow builds a filtered copy of a WordPress plugin's tree
(dropping tests, CI config, and other dev-only paths) onto a release branch, tags that commit,
and publishes the GitHub Release from it.
This exists because a WordPress plugin's update check normally reads whatever commit a version
tag points at (via GitHub's /releases/latest API and its zipball_url, an archive of that
exact commit). Tagging the plugin's normal development branch directly means client sites
receive its entire tree, tests and CI config included. Tagging a separate, always-filtered
release branch instead means client sites only ever receive what they need to run.
CiviCRM extensions have a different release/distribution mechanism (civicrm.org's extension directory reads git tags directly, with its own versioning conventions), so they need their own equivalent workflow rather than reusing this one; that hasn't been built yet.
.github/workflows/wordpress-plugin-cut-release.yml
The release branch must already exist (branched once from the plugin's default branch, with
that repo's excluded paths removed in the first commit) before this workflow is ever run; it
updates the branch, it does not create it.
Add a workflow such as .github/workflows/cut-release.yml, triggered manually from the Actions
tab:
name: Cut release
on:
workflow_dispatch:
inputs:
VERSION:
description: 'Version to release (must match the Version header in the plugin file)'
required: true
type: string
permissions:
contents: write
jobs:
cut-release:
uses: agileware/ci-workflows/.github/workflows/wordpress-plugin-cut-release.yml@main
with:
VERSION: ${{ inputs.VERSION }}
VERSION_FILE: my-plugin.php
EXCLUDE_PATHS: "tests .github CONTRIBUTING.md composer.json composer.lock"
# Optional
# RELEASE_BRANCH: release
# PLUGIN_NAME: my-pluginVERSIONβ version to tag and release, matching the calling repo's existing tag naming (e.g.2.0.6).VERSION_FILEβ path (relative to the repo root) to the plugin file whoseVersion:header must equalVERSION. The job fails if they don't match, rather than tagging/publishing under the wrong version.EXCLUDE_PATHSβ space-separated repo-relative paths to drop from the release branch. Specific to each plugin's own dev-only paths.RELEASE_BRANCHβ branch that only ever holds filtered, release-ready content. Defaults torelease. Must already exist.PLUGIN_NAMEβ human-readable name, used only in log/commit wording. Defaults to the calling repository's name.
The calling workflow must declare permissions: contents: write itself, in addition to the
reusable workflow doing so: a reusable workflow's effective permissions are the intersection of
both, and this job pushes commits/tags and creates a GitHub Release.
- Building the release branch resets it to the triggering commit (
git reset --hard), then removesEXCLUDE_PATHSas a follow-up commit, rather than copying file contents onto a fresh commit on top of release's own history. This is deliberate:release's tip needs every commit on the default branch as a real ancestor, orgh release create --generate-notes(and any other ancestry-based changelog) has nothing to walk and silently omits everything merged since release was last cut. phpunit.xml.distis always stripped too, regardless ofEXCLUDE_PATHS: it's PHPUnit's own test-suite config, useless withouttests/(already excluded by convention), and has no runtime purpose on a client site.- Because of that reset, updating
releaseis pushed with--force, it is essentially never a fast-forward of release's own previous tip. Nobody should develop directly onrelease, its history gets rewritten on every cut. - If the resulting tree is identical to the release branch's current tip (nothing to release), the job still tags and publishes, it just skips creating an empty commit.
- The GitHub Release is created with
--generate-notes, so it's worth keeping merge commit messages/PR titles meaningful on the plugin's default branch.
The CiviCRM-extension counterpart to the workflow above. Same idea (a filtered release branch
gets tagged instead of the extension's default branch), but versioned from info.xml rather
than a plugin file header, since civicrm.org's extension directory reads the tag's tree
directly (no GitHub-specific release-asset or zipball_url step to account for).
Before building the release branch, this workflow also standardises the extension's
documentation structure on the calling branch itself (mkdocs.yml, a docs/ directory, an
absolute-linked docs/README.md, docs/logo/agileware-logo.png, and info.xml's
Documentation URL), pushing that as a normal commit before the filtered release branch is
built from it. See Documentation Structure below.
.github/workflows/civicrm-extension-cut-release.yml
The release branch must already exist (branched once from the extension's default branch,
with that repo's excluded paths removed in the first commit) before this workflow is ever run;
it updates the branch, it does not create it.
Add a workflow such as .github/workflows/cut-release.yml, triggered manually from the Actions
tab:
name: Cut release
on:
workflow_dispatch:
inputs:
VERSION:
description: 'Version to release (must match <version> in info.xml)'
required: true
type: string
permissions:
contents: write
jobs:
cut-release:
uses: agileware/ci-workflows/.github/workflows/civicrm-extension-cut-release.yml@main
with:
VERSION: ${{ inputs.VERSION }}
EXCLUDE_PATHS: ".github tests"
# Optional
# RELEASE_BRANCH: release
# EXTENSION_NAME: my_extensionVERSIONβ version to tag and release, matching the calling repo's existing tag naming (e.g.2.0.2).EXCLUDE_PATHSβ space-separated repo-relative paths to drop from the release branch. Specific to each extension's own dev-only paths.RELEASE_BRANCHβ branch that only ever holds filtered, release-ready content. Defaults torelease. Must already exist.EXTENSION_NAMEβ human-readable name, used only in log/commit wording. Defaults to the calling repository's name.
Same as the WordPress workflow: the calling workflow must declare permissions: contents: write
itself, in addition to the reusable workflow doing so.
- The version check reads
info.xml's<version>element, not a plugin file header. Bump<releaseDate>alongside it, that's on the extension author, this workflow doesn't touch it. - civicrm.org's directory doesn't require a GitHub Release to exist, it reads the tag directly,
but one is still published here (
--generate-notes) to match the existing convention on these repos of every tag having a matching Release. - Otherwise identical to the WordPress workflow: same git-plumbing approach to building the filtered tree, same "nothing to release" no-op handling.
mkdocs.ymlmust not be inEXCLUDE_PATHS: it ships in the release because the mkdocs server builds/serves the documentation site directly from it, same asdocs/itself.- The last step pings
https://docs.civicrm.org/admin/publish/<shortname>/en/master(an emptyPOST) to tell civicrm.org's own docs hosting to rebuild from the just-pusheddocs/.<shortname>isinfo.xml's<file>element, lowercased. This is best-effort: a non-2xx response or an unreachable host only logs a::warning::, it never fails the job, the release itself (tag, GitHub Release) is already complete by this point.
Every cut-release run checks the calling branch (not the release branch) for the standard
docs layout used across these extensions (see au.com.agileware.eventmanagelocations and
au.com.agileware.ewayrecurring), and brings it up to date before the release branch is built
from it:
- Creates
mkdocs.ymlif missing, filling insite_name/repo_url/site_descriptionfrominfo.xml's<name>element and the calling repository, from the template in ci-workflows' owndocs-template/. - Creates
docs/if missing. - Moves
README.mdintodocs/README.mdif the latter doesn't exist yet (a move, not a copy, there's no reason to keep two diverging copies oncedocs/README.mdis the published one). - Rewrites every relative link/image in
docs/README.mdto an absolutehttps://github.com/<repo>/blob/<default-branch>/...(or.../raw/...for images) URL. mkdocs only serves files underdocs/, so a relative link that resolves correctly when GitHub renders the file in place breaks once the same content is built into a standalone mkdocs site. A README just moved from the repo root (step 3, this run) has links written relative to the root; one that already lived indocs/(a prior run, or a repo that started there, likeewayrecurring) has links written relative todocs/itself, this step resolves against whichever base actually applies. - Creates
docs/logo/agileware-logo.pngif missing, copied from ci-workflows'docs-template/logo/, not from anything already in the calling repo, so every extension ends up with the same asset. If this step actually creates it (i.e. it was missing), and an old root-levellogo/agileware-logo.pngexists (from before this extension had adocs/structure), that old file is removed too, it's unreferenced the momentdocs/README.md's own logo link points atdocs/logo/instead. The directory goes too, but only once it's empty, in case some repo's rootlogo/ever holds something else alongside it. - Updates (or adds)
info.xml'sDocumentationURL to point atdocs/README.md, since that's civicrm.org's extension directory's own source for that link, and it needs to follow the move in step 3.
Each check is independent and idempotent, a repo that already has some or all of this (like
ewayrecurring) only gets the parts it's actually missing brought up to date, and a repo that's
fully up to date produces no commit at all. Verified, before this was ever used for real, by
extracting each step's script and running it against: a throwaway repo seeded with a real
extension's pre-migration README.md/info.xml (reproduced au.com.agileware.ewayrecurring's
real historical "Use absolute GitHub URLs" commit byte for byte), and a throwaway repo seeded
with au.com.agileware.ewayrecurring's current, already-migrated docs/, which came back
unchanged.
A one-time setup workflow for a CiviCRM extension repo that doesn't have the Cut Release
workflow yet. Preparing a repo for civicrm-extension-cut-release.yml by hand means writing
its caller workflow, writing a CONTRIBUTING.md process note, branching/stripping/pushing the
initial release branch, and adding whichever of this org's other reusable CI workflows
(civix-upgrade.yml, civicrm-phpunit-tests.yml, playwright-tests.yml) the repo doesn't
already have a caller for, this does all of that in one dispatch.
.github/workflows/civicrm-extension-init-release.yml
Add a workflow such as .github/workflows/init-release.yml, triggered manually from the
Actions tab. Unlike the Cut Release caller, this file never needs editing per repo, the exclude
list is supplied at dispatch time instead of baked in:
name: Init release
on:
workflow_dispatch:
inputs:
EXCLUDE_PATHS:
description: 'Space-separated repo-relative paths to drop from the release branch (e.g. ".github tests .idea")'
required: true
type: string
permissions:
contents: write
jobs:
init-release:
uses: agileware/ci-workflows/.github/workflows/civicrm-extension-init-release.yml@main
secrets:
WORKFLOW_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}
with:
EXCLUDE_PATHS: ${{ inputs.EXCLUDE_PATHS }}Run it once from the Actions tab with the exclude paths for that repo (inspect the repo first,
same judgement call as before: dev-only CI config, test suites, IDE folders like .idea, but
not mkdocs.yml or an extension's own shipped docs/, both of which ship in the release). It
then:
- Writes
.github/workflows/cut-release.yml(pre-filled with theEXCLUDE_PATHSyou passed) and aCONTRIBUTING.mdprocess note. - Writes
.github/workflows/civix-upgrade.ymlif the repo doesn't already have one, so every extension getscivix upgradePRs opened automatically. - Writes
.github/workflows/phpunit-tests.ymlif the repo has aphpunit.xml.distat its root and doesn't already have this workflow, since there's nothing to test otherwise. - Writes
.github/workflows/frontend-tests.ymlif the repo hastests/playwright/playwright.config.tsand doesn't already have this workflow, same reasoning. Also wires inSETUP_SCRIPT: tests/playwright/fixtures/setup-environment.shwhen that file exists too, matching the convention already used elsewhere,SETUP_SCRIPTis otherwise left out rather than guessed at. - Commits whatever combination of the above was actually added, plus
CONTRIBUTING.md, in one commit, and pushes straight to the calling branch. - Branches
releasefrom that commit, stripsEXCLUDE_PATHSfrom it, and pushes it as a new branch on origin.
Steps 2-4 never touch a file that's already there, re-running this workflow (it refuses to, per the double-init guard below, but the individual file-writing steps are harmless no-ops on their own too) never overwrites a hand-tuned existing workflow.
After it finishes, the repo is ready for civicrm-extension-cut-release.yml exactly as if it
had been set up by hand, dispatch "Cut release" the normal way to test it.
EXCLUDE_PATHSβ space-separated repo-relative paths to drop from the release branch. Baked verbatim into the generatedcut-release.ymland into theCONTRIBUTING.mdnote.RELEASE_BRANCHβ name for the branch this workflow creates. Defaults torelease. Must not already exist, this workflow creates it, it doesn't update one.EXTENSION_NAMEβ human-readable name, used only in log wording. Defaults to the calling repository's name.
WORKFLOW_TOKENβ a personal access token that is allowed to push changes under.github/workflowsin the calling repository: a classic token with therepoandworkflowscopes, or a fine-grained token with Contents and Workflows read and write access to that repository. Add it to the repository (or organisation) as an Actions secret and pass it through as shown above. The workflow uses it to check out the repository, so its pushes (the workflow files, then thereleasebranch) are made with it.
The default GITHUB_TOKEN cannot be used instead: GitHub refuses to let it create or change
files under .github/workflows, and there is no workflows entry for the permissions: key
(a workflow that declares one is rejected as invalid). The calling workflow only needs
permissions: contents: write.
- Refuses to run if
info.xmlis missing (not a CiviCRM extension), if.github/workflows/cut-release.ymlalready exists, or ifRELEASE_BRANCHalready exists on origin, so it's safe to leave this workflow file in a repo permanently without risking a double-init. - The generated
cut-release.ymlcontains a literal${{ inputs.VERSION }}expression, written via GitHub's documented${{ '${{' }}escaping trick so that this workflow's own templating pass doesn't try to evaluate it (this workflow has noVERSIONinput to evaluate it against). Verified by extracting and running eachrun:step's script directly against a throwaway local repo before this workflow was first used for real. - GitHub scans the whole file for expressions, comments in
run:steps included. An opening expression delimiter that is not followed by a complete expression anywhere in this file, even inside a shell comment, makes the entire workflow invalid ("failed to parse workflow"). The frontend tests step therefore builds the delimiter from two quoted pieces instead of writing it out. Keep that in mind when editing the templates. - The generated
CONTRIBUTING.mdnames the calling repository's default branch rather than assumingmaster. - Same git-plumbing approach as the Cut Release workflows otherwise: a straightforward push for
the first commit on the calling branch, then a fresh
releasebranch built directly from it (no reset/force-push needed yet, there's no priorreleasehistory to reconcile).