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 version field in each module.json is the authoritative declaration. CI enforces that this field is bumped any time a module's source changes.
  • Lock file: modules/manifests/<ring>.lock.json pins every module to an exact (version, sha256) pair per deployment ring. The default ring is stable. 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>.zip to the GitLab Generic Package Registry.
  • Installation: php artisan module:install <alias>@<version> pulls the artifact, verifies SHA256, atomically swaps it into modules/, and updates the module_versions table.
  • Compatibility: each module.json may declare compatibility.platform: "^2.0.0", which is enforced at module-loader boot against /VERSION at 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

  1. Work in autocommerce/main exactly like any other change.
  2. Edit files under modules/<Name>/.
  3. Bump the version field in modules/<Name>/module.json following semantic versioning:
    • 1.0.0 → 1.0.1 for bug fixes
    • 1.0.0 → 1.1.0 for new features (backwards compatible)
    • 1.0.0 → 2.0.0 for breaking changes
  4. Add a CHANGELOG.md entry for minor and major bumps (advisory check; patch bumps are exempt).
  5. Run php artisan module:lock (defaults to --ring=stable) to update modules/manifests/stable.lock.json so the new SHA256 is captured. Use --ring=<name> to update a different ring's lock file.
  6. 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 version field in module.json
  • Re-run php artisan module:lock to 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.json with 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_NAME via dotenv to downstream jobs

release-module

  • Uploads <Name>-<version>.zip, <Name>-<version>.zip.sha256, and MANIFEST.json to the project's GitLab Generic Package Registry at module/<version>/

create-release-entry

  • Calls the GitLab Releases API directly (avoids the release-cli binary 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

  1. FetchModuleRegistryClient downloads the zip + the .sha256 sidecar + MANIFEST.json to storage/app/module-registry-cache/.
  2. Verify — SHA256 of the downloaded zip is compared to the sidecar; mismatch aborts.
  3. Stage — zip is extracted to storage/app/module-versions/<alias>/<version>/. This staged directory is retained for fast rollback.
  4. SwapModuleInstaller backs up the current modules/<Name>/ contents in-place, wipes them, then copies the new contents from staging. On any failure, the backup is restored.
  5. Migrate — central database migrations from the new version are run (unless --skip-migrations).
  6. Hook — the platform lifecycle hook (onInstall, onUpgrade, or onDowngrade) fires if the module declares a handler in module.json under lifecycle.
  7. DB bookkeeping — the modules and module_versions tables are updated atomically. Previous versions get is_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 timeModuleLoaderService refuses 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 gatemodule:verify (run by module-lock-validate in CI) fails if any module's constraint is violated.
  • Operator toolphp artisan module:compat produces 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