Shipping dotCMS Plugins with Confidence in an Evergreen World
dotCMS Cloud ships updates frequently. If you run in dotCMS cloud and maintain OSGi plugins, that "Evergreen" release cadience is both a feature and a challenge — every release is a potential breaking change for your bundles. This post walks through how we structured the dotcms-community/plugin-examples repo to give plugin developers a reference pattern that stays green across every dotCMS release, automatically.
The Problem: Plugins and Evergreen Platforms Don't Mix Well (by Default)
dotCMS is deployed as a managed evergreen SaaS. Customers extending the platform through OSGi plugins need confidence that those plugins will survive the next update — and the one after that, and the one after that. The classic failure mode is silent: a dotCMS update ships, a dependency inside your bundle gets pulled into the platform at a different version, your bundle stalls at OSGi state RESOLVED (4) instead of ACTIVE (32), and you find out when a REST endpoint or custom workflow actionlet stops firing in production.
The answer isn't to slow down updates. It's to make compatibility testing cheap and automatic.
Example Repo Structure: Maven Parent + Submodules
Every plugin in the repo is an independent Maven project, but they all share a common parent defined at the root pom.xml. This is a standard Maven multi-module layout:
plugin-examples/
├── pom.xml ← parent POM
├── com.dotcms.actionlet/
│ └── pom.xml ← inherits from parent
├── com.dotcms.hooks/
│ └── pom.xml
├── com.dotcms.rest/
│ └── pom.xml
└── ... (22 plugins total)
The parent POM does three things:
1. Sets the Java version in one place
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<dotcms-core.version>26.03.27-01</dotcms-core.version>
</properties>
Every submodule inherits this. When dotCMS bumps its Java requirement, you change one line.
2. Manages the dotcms-core dependency version centrally
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.dotcms</groupId>
<artifactId>dotcms-core</artifactId>
<version>${dotcms-core.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</dependencyManagement>
Submodules declare the dependency without a version — they just get whatever the parent says. Upgrading all 22 plugins to a new dotCMS release is a single property change.
3. Enforces the correct JDK at build time
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<requireJavaVersion>
<version>[21,)</version>
</requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Each submodule's pom.xml is minimal — it just declares its own artifact identity and any plugin-specific dependencies, then inherits everything else:
<parent>
<groupId>com.dotcms</groupId>
<artifactId>plugin-examples</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
Building the whole repo is a single command from the root:
mvn package -DskipTests
Two Layers of CI
Layer 1: Build Check on Every PR
A lightweight workflow compiles every plugin on every push and pull request. It catches compile errors before anything ships:
- name: Build All Plugins
run: mvn --batch-mode --no-transfer-progress package -DskipTests
This is your fast feedback loop — it runs in under two minutes.
Layer 2: Real Installation Test Against a Live dotCMS Container
The more interesting workflow goes further. On every push to main (and on a nightly schedule), it:
Pulls a
dotcms/dotcms-devcontainerDrops all built JARs into the OSGI FileInstall watched directory
Waits for dotCMS to become healthy
Calls
/api/v1/osgiand asserts every bundle reached state32(ACTIVE)
docker run -d \
-e DOT_FELIX_FELIX_FILEINSTALL_DIR=/srv/dotserver/plugins \
-v "${PLUGINS_DIR}:/srv/dotserver/plugins" \
dotcms/dotcms-dev:nightly
This is a real runtime test. It catches OSGi wiring failures, missing package requirements, and activator crashes that a compile check will never surface. Each run posts a bundle report to the GitHub Actions step summary:
STATUS STATE SYMBOLIC NAME
---------- ----- -------------
ACTIVE 32 com.dotcms.hooks
ACTIVE 32 com.dotcms.rest
ACTIVE 32 com.dotcms.actionlet
...
The Hidden Requirement: osgi-extra.conf
Getting a plugin to compile is the easy part. Getting it to actually reach ACTIVE state at runtime is where most plugin developers first hit a wall — and osgi-extra.conf is usually why.
How OSGi Package Resolution Works
dotCMS runs on Apache Felix, an OSGi container. Every bundle declares the Java packages it needs in its MANIFEST.MF:
Import-Package: com.dotmarketing.portlets.contentlet.business,
com.dotcms.contenttype.model.type,
com.dotcms.security.apps
When Felix loads your bundle, it tries to wire each import to a package exported by another bundle in the container. If any import can't be resolved, your bundle stalls at state 4 (RESOLVED) and never reaches 32 (ACTIVE). No error, no stack trace in most cases — it just silently fails to start.
The packages that Felix will export from the dotCMS core are controlled by a file called osgi-extra.conf. This is a comma-separated whitelist of every package the platform makes available to OSGi bundles.
What's in the File
The osgi-extra.conf in this repo is the configuration used by the CI test container. It's a comprehensive list covering the full dotCMS API surface — content types, workflows, REST endpoints, hooks, Velocity, Liferay portal internals, third-party libraries like Jersey, Guava, Jackson, and more:
com.dotmarketing.portlets.contentlet.business,
com.dotcms.contenttype.model.type,
com.dotcms.security.apps;version=0.1.0,
com.dotcms.api.system.event,
com.dotmarketing.portlets.workflows.business,
org.glassfish.jersey.server;version=2.47.0,
com.fasterxml.jackson.databind.annotation;version=2.17.2,
...
The list has hundreds of entries. If a package your plugin imports isn't in this list, your bundle will fail to wire — even if the class physically exists in the dotCMS JAR.
Why This Gets You in Evergreen Environments
dotCMS updates can change what's in this list in three ways:
Packages added — new APIs become available; your plugin can start using them
Packages renamed or moved — your existing import breaks silently
Packages removed — dotCMS internals that were previously exported get sealed away or removed
This is the exact failure mode that running against :nightly and release images catches. A compile-only check won't surface it — only a live OSGi wiring check will.
What To Do When Your Bundle Stalls at State 4
If the CI test reports RESOLVED (4) instead of ACTIVE (32), the bundle report will show missing package requirements extracted from the Felix logs:
Plugin: com.dotcms.myplugin
Missing packages:
com.dotmarketing.portlets.somepackage.business
The fix is to add the missing package to your osgi-extra.conf. But before you do, check whether the package still exists in the new dotCMS version — it may have been renamed, in which case you need to update your import in the plugin source as well.
The automated release-polling workflow catches this within hours of a dotCMS update, so you're never discovering a broken plugin from a customer report.
Bundle Your Own Dependencies — Don't Rely on dotCMS's
There's a related best practice worth calling out explicitly: bundle your own third-party dependencies inside your plugin JAR rather than importing them from dotCMS's classpath.
dotCMS ships with a large set of transitive libraries — Jackson, Guava, Apache Commons, Jersey, and many others. These show up in osgi-extra.conf and are technically importable by your plugin. It's tempting to use them since they're already there, but it makes your plugin fragile in an evergreen environment for a simple reason: we update those libraries constantly. A dependency you're importing transitively through dotCMS today may be at a different version — or gone entirely — in the next release.
The OSGi model actually gives you the right tool for this: the maven-bundle-plugin Embed-Dependency instruction lets you shade third-party JARs directly into your bundle:
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<configuration>
<instructions>
<Embed-Dependency>
my-library;scope=compile
</Embed-Dependency>
<Import-Package>
<!-- only dotCMS APIs you actually need -->
com.dotmarketing.portlets.contentlet.business,
*;resolution:=optional
</Import-Package>
</instructions>
</configuration>
</plugin>
The rule of thumb:
Dependency type | What to do |
|---|---|
dotCMS APIs (com.dotmarketing.*, com.dotcms.*) | Import from platform — these are the stable contract |
dotCMS's transitive libs (Jackson, Guava, Commons) | Bundle your own copy at the version you need |
Your own business logic dependencies | Always bundle |
Keeping your Import-Package list tight — only the dotCMS API surface you actually use, nothing else — is what makes plugins resilient across updates. The fewer packages you import from the platform, the less surface area there is for a dotCMS release to break you.
Staying Current: Automated Release Polling
The nightly test against :nightly tells you if the latest development build is compatible. But dotCMS releases tagged versions like 26.03.27-01 independently, and that's what customers actually run.
A scheduled workflow polls dotcms/core on GitHub every four hours:
on:
schedule:
- cron: '0 */4 * * *'
When a new release is detected, it:
Updates
dotcms-core.versionin the parentpom.xmlviasedBuilds all 22 plugins against the new version
Runs the full installation test using the matching release image (
mirror.gcr.io/dotcms/dotcms-dev:26.03.27-01)Opens a pull request with the version bump if tests pass
- name: Open version-bump PR
run: |
BRANCH="chore/dotcms-core-${{ env.DOTCMS_VERSION }}"
git checkout -b "${BRANCH}"
git add pom.xml
git commit -m "chore: bump dotcms-core.version to ${{ env.DOTCMS_VERSION }}"
git push origin "${BRANCH}"
gh pr create \
--title "chore: bump dotcms-core.version to ${{ env.DOTCMS_VERSION }}" \
--body "Automated version bump — plugin tests passed against dotcms/dotcms-dev:${{ env.DOTCMS_VERSION }}."
The last tested version is stored as a GitHub Actions repository variable (LAST_TESTED_DOTCMS_VERSION). If the latest release matches the stored value, the heavy test job is skipped entirely — so the check stays cheap on quiet days.
The result: within four hours of a dotCMS release, you know whether your plugins still work, and if they do, you have a PR ready to merge.
What's Left (Up to you):
Failure Notifications
The one gap in this setup is alerting. Right now, if the install test fails against a new dotCMS release — meaning a real compatibility break — the failure sits silently in GitHub Actions until someone notices. For a repo meant to give customers confidence, that's not good enough.
The fix is straightforward: add a notification step that fires on test failure, before the PR is opened:
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": ":red_circle: Plugin tests FAILED against dotcms/dotcms-dev:${{ env.DOTCMS_VERSION }}",
"attachments": [{
"text": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Or for email, GitHub's built-in notification settings will fire on workflow failure if you have them enabled — but a Slack alert (Or MS Team) to a dedicated #dotcms-plugin-compat channel gives the whole team immediate visibility without anyone having to watch their inbox.
The CD of CI/CD
It is simple to create an custom GH action that monitors your plugins' version number and when it sees that change, runs the tests and then uses a dotCMS API token to push your newly built and validated plugins to your dotCMS Environment(s). You could get clever and push different branches to different environments, dev, auth and prod.
Using This as a Template
If you maintain dotCMS plugins for your organization, the pattern is worth copying directly:
Multi-module Maven layout with a parent POM controlling Java version and
dotcms-core.versionBuild check on every PR — fast compile validation
OSGi install test against the nightly Docker image on every push to main
Release poller that bumps the version, runs the install test, and opens a PR automatically
Failure alert so broken compatibility is caught within hours, not days
The full repo is open source. Fork it, replace the example plugins with your own, wire up the Slack webhook, and you have a continuously-validated plugin suite that keeps pace with dotCMS evergreen releases automatically.
