Module Versioning
Every module in AutoCom 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
autocommerce/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
autocommerce/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
autocommerce/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 (autocommerce/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:autocommerce/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 AutoCom 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 for fast rollback. - 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. - Migrate — central database migrations from the new version are run (unless
--skip-migrations). - 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.
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 AutoCom 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 autocommerce/modules/* and autocommerce/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