Module Versioning
Every module in Commstate is independently versioned, released, and installable from a central artifact registry. This page explains the full lifecycle from a developer making a change all the way through to a tenant running the new version.
At a Glance
- Single source of truth: the
versionfield in eachmodule.jsonis the authoritative declaration. CI enforces that this field is bumped any time a module's source changes. - Lock file:
modules/manifests/<ring>.lock.jsonpins every module to an exact(version, sha256)pair per deployment ring. The default ring isstable. Day-1 you only have one lock file (stable.lock.json); when you onboard rings, each gets its own. - Distribution: every tag of the form
v<semver>on a module mirror repo triggers a release pipeline that publishes a<Name>-<version>.zipto the GitLab Generic Package Registry. - Installation:
php artisan module:install <alias>@<version>pulls the artifact, verifies SHA256, atomically swaps it intomodules/, and updates themodule_versionstable. - Compatibility: each
module.jsonmay declarecompatibility.platform: "^2.0.0", which is enforced at module-loader boot against/VERSIONat the repo root.
Repository Topology
commstate/main ← canonical development repo
├── VERSION ← platform version (e.g. "2.0.0")
├── modules/
│ ├── manifests/ ← per-ring lock files (one per deployment ring)
│ │ ├── stable.lock.json ← default ring, what every tenant uses on day 1
│ │ ├── edge.lock.json ← optional faster-moving ring
│ │ └── canary.lock.json ← optional bleeding-edge ring
│ ├── module.schema.json
│ ├── Orders/
│ │ ├── module.json ← version: "1.4.2"
│ │ ├── CHANGELOG.md
│ │ ├── backend/
│ │ ├── frontend/
│ │ └── .gitlab-ci.yml ← references php-module.yml from main
│ └── …
└── ci/
├── templates/
│ ├── php-module.yml ← release pipeline (used by mirror repos)
│ ├── module-split.yml ← main → mirror auto-mirror
│ └── modules-versioning.yml ← MR enforcement
└── scripts/
├── check-module-versions.sh
├── validate-module-lock.sh
├── check-module-changelogs.sh
└── split-and-mirror.sh
commstate/modules/<alias> ← per-module mirror repo (one per module)
└── (subtree split of modules/<Name>/ from main)
The mirror repos receive automated pushes from the main repo on every merge. Developers do not clone or push to mirror repos directly — they exist only as release targets.
Developer Workflow
Making a code change
- Work in
commstate/mainexactly like any other change. - Edit files under
modules/<Name>/. - Bump the
versionfield inmodules/<Name>/module.jsonfollowing semantic versioning:1.0.0 → 1.0.1for bug fixes1.0.0 → 1.1.0for new features (backwards compatible)1.0.0 → 2.0.0for breaking changes
- Add a
CHANGELOG.mdentry for minor and major bumps (advisory check; patch bumps are exempt). - Run
php artisan module:lock(defaults to--ring=stable) to updatemodules/manifests/stable.lock.jsonso the new SHA256 is captured. Use--ring=<name>to update a different ring's lock file. - Commit and push your branch.
What CI enforces in your MR
| Job | Behavior |
|---|---|
module-version-check |
Fails the MR if any module's source files changed but its version field didn't bump. Also fails on semver regressions (1.0.0 → 0.9.0). |
module-lock-validate |
Runs the equivalent of php artisan module:verify. Fails if modules/manifests/stable.lock.json doesn't match the filesystem. |
module-changelog-check |
Advisory only — warns if a minor or major bump is missing a CHANGELOG.md entry. |
If module-version-check fails, the fix is always one of:
- Bump the
versionfield inmodule.json - Re-run
php artisan module:lockto regenerate the lock file - Commit the result
After the merge to main
The module-split job in main's pipeline runs git subtree split on every changed module and force-pushes the resulting history to the corresponding mirror repo (commstate/modules/<alias>). If module.json's version field changed, the mirror is also tagged with v<version>, which triggers the release pipeline on the mirror.
Release Pipeline
The release pipeline lives in ci/templates/php-module.yml and runs on every v* tag push in a module mirror repo. It has three jobs:
package-module
- Reads
module.json, confirms the tag matches the version field - Writes a
MANIFEST.jsonwith build provenance (commit SHA, pipeline ID, timestamp) - Zips the module source (excluding
node_modules,vendor,.git,dist,build,.next,tests, lock files,.DS_Store,.gitlab-ci.yml) - Computes SHA256 checksum
- Exposes
MODULE_NAME,MODULE_VERSION,MODULE_ALIAS,ARTIFACT_NAMEvia dotenv to downstream jobs
release-module
- Uploads
<Name>-<version>.zip,<Name>-<version>.zip.sha256, andMANIFEST.jsonto the project's GitLab Generic Package Registry atmodule/<version>/
create-release-entry
- Calls the GitLab Releases API directly (avoids the
release-clibinary which panics on ARM64 runners) - Creates a GitLab Release named
<Name> v<version>with three asset links
After the pipeline succeeds, the artifact is available at:
https://gitlab.wexron.io/api/v4/projects/<encoded-project-path>/packages/generic/module/<version>/<Name>-<version>.zip
The Lock File
Each ring's lock file (modules/manifests/<ring>.lock.json) pins every module:
{
"$schema": "./manifest.lock.schema.json",
"platform": "2.0.0",
"generated_at": "2026-04-12T19:54:19+00:00",
"modules": {
"orders": {
"version": "1.0.0",
"source": "gitlab:commstate/modules/orders",
"artifact": null,
"sha256": "sha256:9ad8f12909c541c029379df6307bfd534a3e4859e6f6370618449979a314b635",
"file_count": 405,
"size_bytes": 1834521,
"released_at": null
}
}
}
The lock file is regenerated by php artisan module:lock. It computes a deterministic SHA256 over each module directory by hashing files in sorted order, excluding node_modules, vendor, .git, dist, build, .next, .cache, coverage, lock files, and .DS_Store. Two machines producing the same source tree always get the same hash.
php artisan module:verify (also exposed as bash ci/scripts/validate-module-lock.sh for CI) checks that the filesystem matches the lock file. It also enforces compatibility.platform constraints.
Installing & Upgrading on a Live Cluster
Once an artifact is published to the registry, install it on any Commstate instance with:
# Configure once via env vars
export MODULE_REGISTRY_URL=https://gitlab.wexron.io
export MODULE_REGISTRY_TOKEN=<personal-or-deploy-token-with-read_package_registry>
# Install at a specific version (refuses if module already installed at another version)
php artisan module:install orders@1.0.0
# Upgrade to a newer version
php artisan module:upgrade orders --to=1.0.1
# Roll back to the previous active version, or to a specific previous version
php artisan module:rollback orders
php artisan module:rollback orders --to=1.0.0
What happens during install/upgrade
- Fetch —
ModuleRegistryClientdownloads the zip + the.sha256sidecar +MANIFEST.jsontostorage/app/module-registry-cache/. - Verify — SHA256 of the downloaded zip is compared to the sidecar; mismatch aborts.
- Stage — zip is extracted to
storage/app/module-versions/<alias>/<version>/. This staged directory is retained so that a later rollback to this version needs no re-download. Note this stages the version being installed, not the one being replaced — see Known limitations. - Swap —
ModuleInstallerbacks up the currentmodules/<Name>/contents in-place, wipes them, then copies the new contents from staging. On any failure, the backup is restored. On success the backup is discarded. - Migrate — central database migrations from the new version are run (unless
--skip-migrations). A migration failure is logged but does not abort the upgrade — see Known limitations. - Hook — the platform lifecycle hook (
onInstall,onUpgrade, oronDowngrade) fires if the module declares a handler inmodule.jsonunderlifecycle. - DB bookkeeping — the
modulesandmodule_versionstables are updated atomically. Previous versions getis_active = false.
Atomicity caveat
The swap uses wipe-and-copy with backup rather than rename(). This is because in container runtimes using overlayfs without redirect_dir=on (the default in Docker Desktop and containerd), renaming a directory that lives in a lower layer fails with EXDEV. This happens whenever the original module came from the Docker image.
There is therefore a brief window during the swap where modules/<Name>/ is empty. This is acceptable for development and most production deployments since modules are loaded into PHP memory at boot and not re-read on every request.
For full atomicity, mount /app/modules on a PVC. With everything in the upper layer, the rename strategy works, and you can switch swapInPlace() to use it.
Schema migration on rollback
module:rollback does not automatically reverse schema migrations. If the version you're rolling back to has a narrower schema, run php artisan migrate:rollback first. The rollback command warns about this every time it runs.
Deployment Prerequisites
Runtime upgrades rewrite modules/<Name>/ on the filesystem of the pod that runs the command. Everything else — modules, module_versions, migrations — is written to the shared database. That asymmetry is the thing to plan around.
Before using module:upgrade on a cluster, one of these must be true:
| Topology | Runtime upgrade safe? |
|---|---|
| Single api replica, single horizon | Yes, until the pod restarts — the swap lives in the container's writable layer and is lost on restart/rollout |
| Multiple replicas, no shared volume | No — see below |
/app/modules on a ReadWriteMany PVC, shared by api + horizon |
Yes, and survives restarts |
| Immutable images (lock file drives an image rebuild) | N/A — upgrades happen via a normal rolling deploy |
Why multiple replicas break it
The default k8s/base/per-ring/api.yaml declares no volumes and no volumeMounts, and the deployment runs more than one replica. modules/ therefore comes from the image and lives in each container's own writable layer. Running module:upgrade then produces a split state:
- exactly one api pod gets the new code
- the other api replicas and the horizon pod keep serving the old code
module_versionsand any applied migrations are global, so the database now claims a version most pods are not running- on the next restart or rollout every pod reverts to the image version, while the database still claims the upgraded one
Nothing detects or repairs this. Verify your topology before running an upgrade:
kubectl -n commstate get deploy api \
-o jsonpath='{.spec.replicas}{" volumes="}{.spec.template.spec.volumes}{"\n"}'
If that prints a replica count above 1 with no volumes, use the image-rebuild path instead: change the version in the ring lock file, rebuild, and deploy. The lock already pins every module by sha256, so the resulting image is reproducible and every pod moves together.
Known Limitations
These are current behaviours of module:upgrade / module:rollback, verified against the implementation. Plan around them.
A failed migration does not fail the upgrade
Step 5 runs migrations and logs the outcome, but a failure does not abort. The version is still swapped in and marked active:
2026_03_10_000001_optimize_orders_table ................... FAIL
Migration run reported error: SQLSTATE[42P01]: Undefined table: 7
ERROR: relation "orders" does not exist
✓ orders upgraded to 1.0.1 (module_version id=1)
Note this is the opposite of the hook contract, where a failed platform hook halts the operation. Always read the migration output — a green checkmark does not mean the schema applied. If migrations failed, re-run them explicitly before letting traffic reach the new code.
Only central migrations run
Step 5 runs migrations against the central connection only. Module tables that are tenant-scoped (orders, products, customers, …) live in per-tenant databases and are not migrated by module:upgrade. Run them yourself afterwards:
php artisan tenants:migrate --force
The first upgrade of a module cannot be rolled back
module:rollback restores files from a previous module_versions row's source_path. A version that arrived with the image has no such row and no staged copy — step 4's backup is discarded once the swap succeeds. So:
$ php artisan module:upgrade orders --to=1.0.1
✓ orders upgraded to 1.0.1
Previous version 1.1.0 retained in storage for rollback. # misleading
$ php artisan module:rollback orders
No previous versions found in module_versions for orders.
Rollback works from the second registry-installed version onward. To recover from a first upgrade, re-install the original version explicitly:
php artisan module:upgrade orders --to=1.1.0
which requires that version to still be published in the registry.
Registry token is required
MODULE_REGISTRY_TOKEN must be set or every fetch fails with HTTP 401. It needs read_api (or read_package_registry) on the module's mirror project. Verify before an upgrade window:
php artisan tinker --execute='echo config("modules.registry.token") ? "SET" : "NOT SET";'
Per-Tenant Versions: Deployment Rings
Everything above describes the single-ring flow: one set of pods, one set of pinned module versions, every tenant on the same code. That's the day-1 setup and stays the default forever.
When you need different tenants on different module versions (canary releases, opt-in beta, enterprise SLA tenants on long-term-stable, etc.), the architecture extends naturally via deployment rings:
- Each ring is its own set of api/horizon/nginx pods running its own pinned manifest
- Tenants are routed to a ring via the ingress layer (one DB column update)
- Promoting a module version from canary → edge → stable is a single command
A typical promotion flow under rings:
# Module is released into the package registry
# (no ring-specific work needed — release pipeline is shared)
# Promote into canary first
php artisan ring:promote orders --from=stable --to=canary --with-version=1.5.0
# Soak for a day or two on canary tenants, then to edge
php artisan ring:promote orders --from=canary --to=edge
# Soak for a week, then to stable (everyone)
php artisan ring:promote orders --from=edge --to=stable
Day-1 you skip rings entirely — the stable ring is the existing single deployment. When you actually need a second ring, see the full Deployment Rings doc for the architecture, k8s overlays, and operator runbook.
Platform Compatibility
Each module may declare a constraint on the Commstate platform version in module.json:
{
"name": "Orders",
"version": "1.4.2",
"compatibility": {
"platform": "^2.0.0"
}
}
Supported constraint syntax:
| Form | Meaning | Example |
|---|---|---|
1.2.3 |
Exact match | matches only 1.2.3 |
^1.2.3 |
Compatible with major | matches 1.2.3 through 1.x.x |
^0.1.2 |
(for 0.x) compatible with minor | matches 0.1.2 through 0.1.x |
~1.2.3 |
Patch-level compatible | matches 1.2.3 through 1.2.x |
>=1.2.3 |
At least | matches 1.2.3 and above |
>=1.2.3 <2.0.0 |
Range (space-separated) | matches [1.2.3, 2.0.0) |
The platform version comes from /VERSION at the repo root (currently 2.0.0).
Enforcement
- Boot time —
ModuleLoaderServicerefuses to register a module whose constraint isn't satisfied. The error is added to the loader's error collection, and dependent modules cascade-fail naturally (anyone consuming the refused module's API also fails). - CI gate —
module:verify(run bymodule-lock-validatein CI) fails if any module's constraint is violated. - Operator tool —
php artisan module:compatproduces a table of every module's constraint vs the current platform version, with explicit pass/fail status.
Example output
Platform: 2.0.0
+--------------------+----------+---------------------+--------------+
| Alias@Version | Dir | Platform Constraint | Status |
+--------------------+----------+---------------------+--------------+
| orders@1.0.0 | Orders | ^2.0.0 | ✓ compatible |
| products@2.0.0 | Products | (none) | ✓ compatible |
| wms@2.0.0 | WMS | ^2.0.0 | ✓ compatible |
+--------------------+----------+---------------------+--------------+
Compatible: 22 / 22
Incompatible: 0 / 22
Data Model
Two tables back the system:
modules
The currently active version of each module. Maintained by ModuleLoaderService::syncModulesToDatabase() and updated by ModuleInstaller::activate().
module_versions
Version history per module. New rows are inserted on every module:install and module:upgrade. Only one row per module has is_active = true at a time.
module_versions
├── id
├── module_id (fk → modules.id)
├── version (semver string)
├── source_path (storage/app/module-versions/<alias>/<version>/)
├── content_hash (raw sha256 hex, no prefix)
├── is_active (bool — exactly one true per module)
├── changelog (text, nullable)
├── size_bytes
├── created_by (string — "module:install", "module:upgrade", etc.)
├── created_at
└── updated_at
The source_path of inactive versions is preserved so module:rollback can swap back without re-downloading.
CI/CD Variables
The release and split pipelines need these variables configured at the project or group level:
| Variable | Where | Scope | Purpose |
|---|---|---|---|
MIRROR_PUSH_TOKEN |
main repo | group access token | write_repository on commstate/modules/* and commstate/themes/* so the split job can push to mirrors. |
CI_JOB_TOKEN |
mirror repos | automatic | Used by release-module to upload to the project's own Package Registry. No setup required. |
For platform installations using module:install outside CI, set:
| Env Var | Purpose |
|---|---|
MODULE_REGISTRY_URL |
Base URL of GitLab (default: https://gitlab.wexron.io) |
MODULE_REGISTRY_TOKEN |
Personal access or deploy token with read_package_registry |
Lifecycle Hooks
A module can declare custom platform lifecycle handlers in module.json:
{
"lifecycle": {
"onInstall": "Modules\\MyModule\\App\\Lifecycle\\OnInstall",
"onUpgrade": "Modules\\MyModule\\App\\Lifecycle\\OnUpgrade",
"onDowngrade": "Modules\\MyModule\\App\\Lifecycle\\OnDowngrade"
}
}
Each handler must implement handle(array $context): void. The context contains:
| Key | Hook | Notes |
|---|---|---|
version |
onInstall |
The version being installed |
fromVersion |
onUpgrade, onDowngrade |
Previous version |
toVersion |
onUpgrade, onDowngrade |
New version |
sourcePath |
all | Absolute path to the staged source directory |
manifest |
all | Parsed MANIFEST.json from the registry artifact |
These hooks fire once per install/upgrade/rollback (not per tenant). For per-tenant logic, use onEnable and onDisable which are tenant-scoped.
Quick Reference
# Local dev
php artisan module:lock # regenerate stable.lock.json
php artisan module:lock --ring=edge # regenerate edge ring lock file
php artisan module:lock --check # exit non-zero if stale
php artisan module:verify # check filesystem matches lock + compat
php artisan module:compat # report compat status of every module
# Releasing a module (developer flow)
# 1. Edit modules/Orders/, bump module.json version
# 2. php artisan module:lock
# 3. git commit, push, MR, merge
# 4. CI auto-mirrors + auto-tags + auto-releases
# Installing on a cluster (operator flow)
export MODULE_REGISTRY_URL=https://gitlab.wexron.io
export MODULE_REGISTRY_TOKEN=glpat-...
php artisan module:install orders@1.0.0
php artisan module:upgrade orders --to=1.0.1
php artisan module:rollback orders --to=1.0.0