Module Versioning — Overview & Reference
This is the single source-of-truth reference for AutoCom's module versioning system. It explains what the system does, why it exists, how it's wired together, and how to operate it day-to-day.
If you only have 2 minutes, read In Plain Terms. If you're operating a cluster, jump to Operator Runbook. If you're debugging, start with Troubleshooting.
In Plain Terms
Before this system, all 25 modules lived in a folder with no version tracking, no way to ship a specific version to a customer, and no way to roll back if something broke. Now:
-
Every module has a version receipt. Each
module.jsondeclares a semantic version, and a per-ring lock file atmodules/manifests/<ring>.lock.json(default ring:stable) records the exact(version, sha256)of every module that ring deploys. Same lock file → same code, every deploy. -
Developers can't accidentally skip versioning. When code changes inside a module without bumping its version, CI blocks the merge. Same if someone tries a version regression (
1.0.0 → 0.9.0). -
Modules build into shippable packages automatically. Tagging
v1.4.2on a module's mirror repo triggers a release pipeline that zips the module, computes a checksum, uploads it to GitLab's Generic Package Registry, and creates a GitLab Release entry — all hands-off. -
Install / upgrade / rollback with one command:
php artisan module:install orders@1.0.0php artisan module:upgrade orders --to=1.0.1php artisan module:rollback ordersThe system downloads the right version from the registry, verifies the checksum, swaps files in place, runs new database migrations, and updates themodule_versionsaudit table. Old versions stay on disk for instant rollback.
-
Modules can refuse to run on incompatible platforms. A module declares
compatibility.platform: "^2.0.0"and the loader refuses to register it if the current/VERSIONdoesn't satisfy that constraint. Dependent modules cascade-fail naturally. -
Everything is auditable. The
module_versionstable records every install/upgrade/rollback with version, hash, source path, and timestamp. You can answer "when did Orders 1.4.2 ship?" with one SQL query.
Why This Was Built
| Problem before | Solution now |
|---|---|
| "Which version of Orders is in production?" had no answer | php artisan module:list + module_versions table |
| Deploys drifted between environments | manifest.lock.json + module:verify |
| A module change could ship without anyone realizing | CI module-version-check blocks unbumped MRs |
| No way to ship a hotfix to one module | Release pipeline per module, independent cadence |
| Bad release? Full backend redeploy + git revert | module:rollback orders --to=1.0.0 |
| Third-party modules from vendors? Impossible | Same package registry pattern works for external publishers |
Architecture
Repository Topology
The system uses a monorepo-canonical model with polyrepo distribution targets:
autocommerce/main ← canonical development repo
├── VERSION ← platform version "2.0.0"
├── modules/
│ ├── manifest.lock.json ← pins every module to (version, sha256)
│ ├── module.schema.json
│ └── Orders/, Products/, …
└── ci/
├── templates/
│ ├── modules-versioning.yml ← CI gate: enforce version bumps + lock validity
│ ├── module-split.yml ← auto-mirror main → per-module repos
│ └── php-module.yml ← release pipeline (used by mirror repos)
└── scripts/
├── check-module-versions.sh
├── validate-module-lock.sh
├── check-module-changelogs.sh
└── split-and-mirror.sh
autocommerce/modules/<alias> ← per-module mirror (one per module)
└── (subtree split of modules/<Name>/ from main)
├── module.json
├── backend/
├── frontend/
├── CHANGELOG.md
└── .gitlab-ci.yml ← references php-module.yml from main
autocommerce/themes/theme-<name> ← themes follow the same pattern
Developers work in autocommerce/main exactly like before. Mirror repos receive automated pushes from main and exist purely as release targets — never clone them, never push to them directly.
Two-Track Deployment Flow
┌──────────────────────────────────────────┐
│ developer commits to autocommerce/main │
│ (bumps modules/Orders/module.json) │
└────────────────┬─────────────────────────┘
│
┌────────────────▼────────────────┐
│ CI: module-version-check │
│ CI: module-lock-validate │
│ CI: module-changelog-check │
└────────────────┬────────────────┘
│ MR merged
┌────────────────▼────────────────┐
│ CI: module-split job runs │
│ - git subtree split per module │
│ - push to mirror repo │
│ - if version changed: tag v* │
└────────────────┬────────────────┘
│ tag triggers
┌────────────────▼────────────────┐
│ CI on mirror repo: │
│ - package-module → zip │
│ - release-module → registry │
│ - create-release-entry → API │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ GitLab Package Registry holds: │
│ Orders-1.4.2.zip + sha256 + │
│ MANIFEST.json │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ Production cluster operator: │
│ $ php artisan module:install │
│ orders@1.4.2 │
└─────────────────────────────────┘
Database Schema
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 on every install/upgrade. Exactly one row per module has is_active = true.
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, varchar(64))
├── is_active bool — exactly one true per module
├── changelog text, nullable
├── size_bytes
├── created_by "module:install", "module:upgrade", etc.
├── created_at
└── updated_at
Inactive versions' source_path is preserved so module:rollback can swap back without re-downloading.
Component Map
| Layer | Component | Purpose |
|---|---|---|
| Backend service | App\Core\Services\ModuleHasher |
Deterministic SHA256 over module source (excludes build artifacts) |
| Backend service | App\Core\Services\PlatformCompatibility |
Reads /VERSION, evaluates compatibility.platform constraints with hand-rolled semver matcher |
| Backend service | App\Core\Services\ModuleRegistryClient |
Downloads + verifies + extracts artifacts from GitLab Package Registry |
| Backend service | App\Core\Services\ModuleInstaller |
Atomic-ish swap of module files, updates DB tables, fires platform lifecycle hooks |
| Backend service | App\Core\Services\ModuleLoaderService (extended) |
Refuses incompatible modules at boot |
| Backend service | App\Core\Services\ModuleManager::runPlatformLifecycleHook() |
Non-tenant-scoped hook dispatcher for onInstall/onUpgrade/onDowngrade |
| Artisan command | module:lock |
Generate/update manifest.lock.json |
| Artisan command | module:verify |
CI gate — filesystem matches lock + compat |
| Artisan command | module:compat |
Report compat status of every module |
| Artisan command | module:install |
Install from registry |
| Artisan command | module:upgrade |
Upgrade installed module |
| Artisan command | module:rollback |
Roll back to prior version |
| CI template | ci/templates/modules-versioning.yml |
MR enforcement (3 jobs) |
| CI template | ci/templates/module-split.yml |
Auto-mirror main → per-module repos |
| CI template | ci/templates/php-module.yml (extended) |
Release pipeline: package + release + create-release-entry |
| CI script | ci/scripts/check-module-versions.sh |
Enforce semver bump on code change |
| CI script | ci/scripts/validate-module-lock.sh |
Inline PHP verifier (CI-equivalent of module:verify) |
| CI script | ci/scripts/check-module-changelogs.sh |
Advisory: warn on minor/major bump without CHANGELOG |
| CI script | ci/scripts/split-and-mirror.sh |
Subtree split + push to mirror + auto-tag |
| CI template | ci/templates/backend-tests.yml |
Runs the 16 Ring PHPUnit contract tests on MRs that touch backend/ or modules/ |
| One-time script | bin/seed-module-mirrors.sh |
Initial seed of mirror repos from main — data-driven: resolves each mirror URL from repository.url in modules/<Name>/module.json, falls back to a small legacy table only for historical off-name repos |
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.json:1.0.0 → 1.0.1for bug fixes1.0.0 → 1.1.0for new features1.0.0 → 2.0.0for breaking changes
- (Recommended) Add a
CHANGELOG.mdentry for minor/major bumps. - Run
php artisan module:lockto update the lock file. - Commit and push your branch.
What CI enforces in your MR
| Job | Behavior |
|---|---|
module-version-check |
Fails if module source changed but version didn't bump. Also fails on regressions. |
module-lock-validate |
Fails if manifest.lock.json doesn't match the filesystem. Run php artisan module:lock to fix. |
module-changelog-check |
Advisory only — warns if a minor/major bump has no CHANGELOG.md entry. |
backend-ring-tests |
Runs the 16 PHPUnit tests in backend/tests/Feature/Rings/* that lock in the Deployment Rings contract: each ring reads from its own lock file, tenants resolve to their assigned ring, and ring:promote mutates only the target ring's manifest. Fails the MR if any assertion breaks — this is how the isolation guarantee stays enforced on every merge. |
After merge to main
The module-split job runs git subtree split on each changed module and force-pushes to the matching mirror repo. If module.json's version changed, the mirror is also tagged with v<version>, which triggers the release pipeline and produces an artifact in the GitLab Package Registry.
How CI rules work (path-based job triggering)
Every CI job has a changes: filter declaring which file paths it cares about. If your commit doesn't touch any of those paths, the job is silently skipped — no compute spent.
| If you change… | Jobs that fire |
|---|---|
modules/<Name>/backend/... (real module code) |
All 3 module gates (version-check, lock-validate, changelog-check) + module-split + build-api + build-horizon |
modules/<Name>/frontend/... |
Same as above + build-frontend |
modules/<Name>/module.json only |
All 3 gates + module-split (auto-tags mirror if version changed) |
modules/manifests/<ring>.lock.json |
Nothing — these are explicitly excluded everywhere |
backend/... (platform Laravel code) |
build-api, build-horizon |
frontend/... (platform Next.js) |
build-frontend |
docs/... |
build-docs |
composer.json / composer.lock |
build-api, build-horizon |
ci/scripts/... or .gitlab-ci.yml |
Nothing — pure CI changes don't trigger anything |
k8s/... |
Nothing — manifests are config, not code |
Any push to main |
deploy-k8s (manual; always present, no changes: filter) |
This means:
- A docs-only MR doesn't burn build minutes
- A CI-script-only MR doesn't burn build minutes (and doesn't trigger module-split)
- Bumping Orders doesn't rebuild the frontend
- A typo fix in repo-root README doesn't fire anything
If a pipeline shows only deploy-k8s and nothing else, the right read is: "this commit didn't change anything that needs CI work." deploy-k8s is a manual button that's always available — not a pending action.
Operator Runbook
Installing a fresh module on a running cluster
export MODULE_REGISTRY_URL=https://gitlab.wexron.io
export MODULE_REGISTRY_TOKEN=<personal-or-deploy-token-with-read_package_registry>
php artisan module:install orders@1.0.0
What happens internally:
ModuleRegistryClientdownloadsOrders-1.0.0.zip,.sha256sidecar, andMANIFEST.jsonfrom the registry- SHA256 verified — mismatch aborts
- Extracted to
storage/app/module-versions/orders/1.0.0/ ModuleInstallerbacks up currentmodules/Orders/, wipes contents, copies new contents- Central database migrations from the new version are run (unless
--skip-migrations) onInstallplatform lifecycle hook firesmodule_versionsrow inserted withis_active = true
Upgrading a module
php artisan module:upgrade orders --to=1.0.1
Same flow as install, plus:
- Previous
module_versionsrow getsis_active = false(kept for rollback) onUpgradehook fires with{fromVersion, toVersion, sourcePath, manifest}context
Rolling back
php artisan module:rollback orders # most recent prior version
php artisan module:rollback orders --to=1.0.0 # specific version
⚠ Schema migrations are NOT auto-reverted. Run
php artisan migrate:rollbackfirst if the version you're rolling back to has a narrower schema.
Checking system health
# Are all modules pinned correctly?
php artisan module:verify
# Are all modules compatible with the current platform version?
php artisan module:compat
# What's installed right now?
php artisan module:list
# Show full version history for a module
php artisan tinker --execute='echo \App\Models\ModuleVersion::with("module")->where(["module_id" => \App\Models\Module::where("alias", "orders")->value("id")])->orderBy("id")->get(["id", "version", "is_active", "created_at"])->toJson(JSON_PRETTY_PRINT);'
Atomicity caveat
The swap uses wipe-and-copy with backup, not atomic rename. This is because in container runtimes using overlayfs without redirect_dir=on (default in Docker Desktop / containerd), renaming a directory from a lower layer fails with EXDEV.
There is a brief window during the swap where modules/<Name>/ is being repopulated. This is acceptable for most production deployments since module code is loaded into PHP memory at boot, not re-read on every request.
For full atomicity, mount /app/modules on a PVC — that puts everything in the upper overlay layer where rename works. Then you can switch swapInPlace() to use it.
Per-Tenant Module Versions (Deployment Rings)
Everything described above runs in a single deployment: one set of api/horizon/nginx pods, one set of pinned module versions, every tenant on the same code. That's the day-1 setup and the right default.
When you need different tenants on different module versions — canary releases, opt-in beta, enterprise tenants on long-term-stable, internal QA dogfooding — the architecture extends via deployment rings:
- Each ring is its own k8s namespace with its own api/horizon/nginx pods
- Each ring pins its own module versions via
modules/manifests/<ring>.lock.json - Tenants are routed to a ring at the ingress layer based on
tenants.module_ring_id - Promoting a module version up the chain is one command + one git commit
The ring system is built on top of the versioning system documented above — same package registry, same install/upgrade/rollback commands, same module.json format. Rings just add the per-process boundary that lets multiple versions coexist on the same cluster.
ring: stable ring: edge ring: canary
Orders @ 1.4.0 Orders @ 1.5.0 Orders @ 1.6.0-beta
────────────── ────────────── ──────────────
k8s ns: k8s ns: k8s ns:
autocom autocom-edge autocom-canary
Tenants: Tenants: Tenants:
acme, foo, … widgets, bar internal-qa
Day-1 you have exactly one ring (stable). Adding a second ring is opt-in via:
kubectl apply -k k8s/overlays/rings/canary # spin up canary pods
php artisan ring:assign internal-qa canary # move one tenant
Operator commands:
php artisan ring:list # all rings + tenant counts
php artisan ring:show stable --tenants --modules # ring detail
php artisan ring:assign acme canary --reason="opt-in" # move a tenant
php artisan ring:promote orders --from=canary --to=edge # promote a version
See Deployment Rings for the full architecture, k8s overlays, hostname routing, and trade-offs.
Complete CLI Reference
module:lock
php artisan module:lock # regenerate the lock file
php artisan module:lock --check # exit non-zero if lock is stale
php artisan module:lock --module=Orders # update one module only
Generates or updates modules/manifest.lock.json so it captures the exact (version, sha256) of every module on disk.
module:verify
php artisan module:verify
CI gate. Checks three things per module: (1) version in module.json matches the lock file, (2) content SHA256 matches, (3) compatibility.platform constraint is satisfied. Non-zero exit on any failure.
module:compat
php artisan module:compat
php artisan module:compat --strict
Tabular report of every module's platform constraint vs the current /VERSION. Non-zero exit if any incompatibility.
module:install
php artisan module:install orders@1.0.0
php artisan module:install orders@1.0.0 --force # reinstall same version
php artisan module:install orders@1.0.0 --skip-migrations
Refuses if the module is already installed at a different version (use module:upgrade for that path).
module:upgrade
php artisan module:upgrade orders --to=1.0.1
php artisan module:upgrade orders --to=1.0.1 --skip-migrations
module:rollback
php artisan module:rollback orders # most recent prior version
php artisan module:rollback orders --to=1.0.0 # specific version
module:lock --ring=<name> (per-ring lock files)
php artisan module:lock # writes stable.lock.json
php artisan module:lock --ring=stable # explicit
php artisan module:lock --ring=edge # writes edge.lock.json
php artisan module:lock --ring=canary --module=Orders
Each ring keeps its own modules/manifests/<ring>.lock.json. module:verify and module:compat accept the same --ring= flag.
ring:list, ring:show, ring:assign, ring:promote
Operator-side commands for the deployment-rings system.
php artisan ring:list # all rings + tenant counts
php artisan ring:show stable --tenants --modules # ring detail
php artisan ring:assign acme canary --reason="opt-in" # move a tenant
php artisan ring:promote orders --from=canary --to=edge # promote a version
See Deployment Rings for the full design and runbook.
Lifecycle Hooks
A module can declare custom platform lifecycle handlers in module.json:
{
"lifecycle": {
"onEnable": "Modules\\MyModule\\App\\Lifecycle\\OnEnable",
"onDisable": "Modules\\MyModule\\App\\Lifecycle\\OnDisable",
"onInstall": "Modules\\MyModule\\App\\Lifecycle\\OnInstall",
"onUpgrade": "Modules\\MyModule\\App\\Lifecycle\\OnUpgrade",
"onDowngrade": "Modules\\MyModule\\App\\Lifecycle\\OnDowngrade"
}
}
| Hook | Scope | Fires on | Use for |
|---|---|---|---|
onEnable |
Per-tenant | Tenant enables module | Seed tenant data, register tenant webhooks |
onDisable |
Per-tenant | Tenant disables module | Clean up tenant resources |
onInstall |
Platform-wide | module:install |
Compile binaries, warm shared caches |
onUpgrade |
Platform-wide | module:upgrade |
Migrate central data formats, invalidate caches |
onDowngrade |
Platform-wide | module:rollback |
Restore central data formats |
Platform handlers receive array $context with {fromVersion, toVersion, sourcePath, manifest}. They run once per operation regardless of how many tenants exist. Failed platform hooks halt the operation; failed tenant hooks are logged but non-fatal.
Setup Checklist (one-time)
After merging feat/module-versioning to main:
-
Set
MIRROR_PUSH_TOKENas a CI/CD variable onautocommerce/main:- Group access token on
autocommercewithDeveloperrole +write_repositoryscope - Mark as
MaskedandProtected - Required for
module-split.ymlto push to mirror repos
- Group access token on
-
Set
MODULE_REGISTRY_TOKENfor production environments:- Personal or deploy token with
read_package_registryscope onautocommerce/modules/* - Add to k8s secret
autocom-secretsand reference fromapideployment env vars - Required for
module:install/upgradeto work in prod
- Personal or deploy token with
-
Seed mirror repos (one-time, from a dev machine with all
gl--modules-*remotes configured):bash bin/seed-module-mirrors.sh --dry-run --all # preview bash bin/seed-module-mirrors.sh --all # actually pushFirst run is a no-op for any mirror that's already in sync. Already verified to work for Orders (the seed produced the same tip commit as the existing mirror).
-
(Optional) Mount
/app/moduleson a PVC in production for true atomic swap. Otherwise the wipe-and-copy strategy works but has a brief non-atomic window. -
(Optional) Widen
module_versions.content_hashtovarchar(80)if you want to store thesha256:prefix natively. Currently we strip it.
CI/CD Variables Reference
| Variable | Where | Type | Purpose |
|---|---|---|---|
MIRROR_PUSH_TOKEN |
autocommerce/main |
Group access token, masked, protected | write_repository on autocommerce/modules/* and autocommerce/themes/* so split-and-mirror.sh can push to mirrors |
CI_JOB_TOKEN |
mirror repos | Automatic, no setup | Used by release-module to upload to the project's own Package Registry |
MODULE_REGISTRY_URL |
production env | Plain env var | Base URL of GitLab (default: https://gitlab.wexron.io) |
MODULE_REGISTRY_TOKEN |
production env | Secret env var | Personal/deploy token with read_package_registry |
Lock File Format
modules/manifest.lock.json is the contract for reproducible deploys:
{
"$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
}
}
}
Generated by php artisan module:lock. Verified by php artisan module:verify.
The hash algorithm walks the module directory in sorted order and SHA256s file contents + relative paths, excluding: node_modules/, vendor/, .git/, dist/, build/, .next/, .cache/, coverage/, storage/framework/, storage/logs/, plus lock files and .DS_Store. Two machines producing the same source tree always produce the same hash.
Implementation History
The system was built in 6 phases. Each phase ended with end-to-end verification before moving on.
Phase 1 — Foundation
VERSIONfile at repo root (platform2.0.0)modules/module.schema.jsonextended withrepository,distribution,compatibility.platform,changelogfieldsApp\Core\Services\ModuleHasher— deterministic content hashingphp artisan module:lock— generatesmanifest.lock.jsonfrom filesystem statephp artisan module:verify— validates filesystem matches lock filemodules/manifest.lock.jsoninitial generation
Verified: all 22 modules hashed cleanly, drift detection works (added a file to Orders, verify failed with clear message), reset.
Phase 2 — CI versioning enforcement
ci/templates/modules-versioning.yml— 3 jobs in main repo pipelineversion-check: blocks MRs that change module source without bumping versionmodule-lock-validate: runs equivalent ofmodule:verifychangelog-check: advisory warning on minor/major withoutCHANGELOG.mdentry- All scripts bash-3.2 compatible (run locally on macOS too)
Verified: 4 scenarios — no change passes, modify-without-bump fails, modify+bump passes, regression fails.
Phase 3 — Release pipeline
bin/seed-module-mirrors.sh— one-timegit subtree splitscript. Originally shipped with a hardcoded 22-module map; now data-driven: resolves each mirror URL fromrepository.urlinmodules/<Name>/module.json, with a small legacy fallback table only for historical off-name repos. Adding a new module requires no script changes — setrepository.urlin itsmodule.jsonand the seed / sync both pick it up automatically. All 23 modules + themes successfully seeded.ci/templates/module-split.yml+ci/scripts/split-and-mirror.sh— auto-mirror main changes to per-module repos, auto-tag on version changeci/templates/php-module.ymlextended with 3 new jobs:package-module: zip + sha256 + MANIFEST.jsonrelease-module: upload to GitLab Generic Package Registrycreate-release-entry: create GitLab Release via direct API call (avoids release-cli ARM64 panic)
Verified: real release of Orders v1.0.0 — pipeline 307 ran on the Orders mirror, produced Orders-1.0.0.zip (141 KB, 128 files), uploaded to registry, created GitLab Release. SHA256 verified locally against the registry artifact.
Phase 4 — Install / upgrade / rollback
App\Core\Services\ModuleRegistryClient— downloads + verifies artifactsApp\Core\Services\ModuleInstaller— atomic-ish swap with backup/restoremodule:install,module:upgrade,module:rollbackartisan commandsModuleManager::runPlatformLifecycleHook()— non-tenant-scoped hook dispatcher
Verified: real upgrade chain on the live k3s cluster — installed Orders 1.0.0, upgraded to 1.0.1, rolled back to 1.0.0. DB state and on-disk state both reflected each step. API stayed HTTP 200 throughout.
Phase 5 — Platform compatibility
App\Core\Services\PlatformCompatibility— central semver matcher (^,~,>=,>,<=,<, exact, ranges like>=2.0 <3.0)ModuleLoaderServicerefuses incompatible modules at bootmodule:compatreports compatibility statusmodule:verifyrefactored to delegate toPlatformCompatibility
Verified: set Orders to require ^99.0.0, loader refused with clear error, dependent modules (StoreShopify, Communications, MobileApp) cascade-failed naturally via the dependency graph. Reset, all clean.
Phase 6 — Documentation + final E2E
versioning.md— workflow doccli-commands.md— extended with new commandslifecycle.md— added platform-level hooks- 8-step cross-cutting smoke test: lock → verify → compat → install → upgrade → rollback → DB confirmation → API health check
Phase 7 — Deployment Rings (per-tenant module versions)
module_ringstable +tenants.module_ring_idFK (every existing tenant auto-migrated tostable)ModuleRingmodel,Tenant.moduleRing()relationshipring:list,ring:show,ring:assign,ring:promoteartisan commandsmodules/manifest.lock.json→modules/manifests/<ring>.lock.jsonper-ring splitmodule:lock --ring=<name>,module:verify --ring=<name>ModuleLoaderService::ringName()readsRING_NAMEenv var, picks the right lock file/api/healthreturns{ring: {name, lock_path}}for operator verificationk8s/overlays/rings/{stable,edge,canary}/Kustomize overlays with strategic merge label patchesci/templates/ring-promote.yml— manual-trigger pipeline for module promotion across ringsdocs/content/docs/modules/versioning-rings.md— full architecture + operator runbook
Verified: on the live cluster — ring:list returns 3 rings, /api/health reports ring=stable, isolation verified by mutating canary's manifest and confirming stable+edge unaffected, multi-module verified by adding experimental-feature to canary only.
Phase 8 — Cleanup (k8s base split + PSA hardening + ring tests)
k8s/base/split intoper-ring/(api, horizon, nginx, network-policies) andshared/(frontend, docs, redis, indian-post). Metakustomization.yamlpreserves backward compat for local/production/staging overlaysk8s/overlays/shared/— new dedicated overlay for the shared layerk8s/overlays/rings/_shared-aliases/— kustomize component with ExternalName services bridging non-stable rings to shared services inautocom- PSA
restrictedcompliance:seccompProfile: RuntimeDefault, container-levelrunAsNonRoot,capabilities: drop ALLeverywhere - nginx switched from
nginx:alpine→nginxinc/nginx-unprivileged:alpine(port 8080) to fix chown failure on read-only root - 3 PHPUnit feature test files in
backend/tests/Feature/Rings/:RingIsolationTest.php(5 tests)RingMultiModuleTest.php(5 tests)RingPromotionTest.php(6 tests)
Verified: all 7 kustomize overlays still build cleanly; ring overlays went from 7 deployments to 3 each (api+horizon+nginx).
Bugs Found & Fixed During Verification
Real bugs the verification phases caught that would have broken production:
| # | Phase | Bug | Fix |
|---|---|---|---|
| 1 | 3 | release-cli Go runtime panic on ARM64 (lfstack.push invalid packing) |
Replaced with direct curl to GitLab Releases API |
| 2 | 3 | create-release-entry had empty dotenv vars, produced HTTP 400 |
Made it needs: package-module directly to inherit dotenv report |
| 3 | 4 | File::copyDirectory flaky in Octane — rename(): cannot be a directory |
Replaced with manual recursiveCopy() using RecursiveDirectoryIterator |
| 4 | 4 | rename() EXDEV on overlayfs lower layer |
Switched to wipe-and-copy strategy with backup-restore |
| 5 | 4 | content_hash varchar(64) overflow with sha256: prefix (71 chars) |
Strip prefix when storing |
| 6 | 5 | composer/semver listed as transitive constraint but not installed |
Hand-rolled matcher in PlatformCompatibility::satisfies() |
| 7 | post-3 | bin/seed-module-mirrors.sh had hardcoded 22-module map; new modules silently skipped |
Made data-driven — reads repository.url from module.json, falls back to legacy table only for off-name historical mirrors |
| 8 | post-3 | module-split.yml ran bash as a git subcommand because alpine/git image's ENTRYPOINT was git |
Override entrypoint: [""] in the image declaration |
| 9 | post-3 | build-docs CI job failed: COPY package.json couldn't find file at repo root |
Changed build context from . to docs to match Dockerfile.prod's expectations |
| 10 | post-7 | Frontend leaked literal __NEXT_PUBLIC_API_URL__ into URLs because entrypoint.sh sed -i failed silently under readOnlyRootFilesystem: true |
Removed readOnlyRootFilesystem from frontend k8s manifest; documented the trade-off inline |
| 11 | 7 | manifests/ subdir treated as a module by 4 different scanners (verify, lock, compat, validate-lock CI) |
Added skip in all 6 places — but missed split-and-mirror.sh which broke pipeline 411 |
| 12 | 7 | JSON Patch add /metadata/labels/ring failed when base manifest had no metadata.labels block |
Switched to strategic merge patch via ring-labels.yaml |
| 13 | 7 | nginx:alpine chown failed under PSA restricted + readOnlyRootFilesystem |
Switched to nginxinc/nginx-unprivileged:alpine (port 8080, no chown step) |
| 14 | 8 | split-and-mirror.sh missing manifests/ skip from #11 — broke main pipeline post-merge of MR !7 |
Added the same skip pattern to bring it in line with the other 5 scanners |
Troubleshooting
MR fails with module-version-check
Cause: You changed code inside modules/<Name>/ but didn't bump module.json's version field.
Fix: Bump the version, run php artisan module:lock, commit, push.
MR fails with module-lock-validate
Cause: modules/manifest.lock.json doesn't match the filesystem state of one or more modules.
Fix: Run php artisan module:lock locally, commit the updated lock file.
Pipeline succeeds but no artifact appears in registry
Cause: The release-module job uploaded successfully but you're checking with the wrong URL/token.
Fix: Visit https://gitlab.wexron.io/<project-path>/-/packages directly. If artifact is there, the issue is auth on your client side. If not, check the release-module job logs.
module:install fails with Fetch failed: HTTP 401
Cause: MODULE_REGISTRY_TOKEN env var is missing, expired, or has the wrong scopes.
Fix: Generate a new personal access token with read_package_registry, set it as the env var, retry.
module:install fails with SHA256 mismatch
Cause: Artifact in the registry was tampered with, or there's a transport corruption (rare). Fix: Re-run the release pipeline on the mirror repo. Worst case, delete the artifact from the registry and re-tag.
module:upgrade swap fails with rename(): cross-device link
Cause: Production cluster has /app/modules baked into the Docker image (lower overlay layer), and the swap is trying to use rename().
Fix: This shouldn't happen because swapInPlace() uses wipe-and-copy specifically to avoid this. If it does, check that the latest ModuleInstaller.php is deployed (look for recursiveCopy() method).
Module loader refuses a module with "Incompatible: platform X does not satisfy Y"
Cause: Module's compatibility.platform constraint doesn't match the current /VERSION.
Fix: Either bump /VERSION (if the module's constraint is correct), or relax the module's constraint, or downgrade the module to a version compatible with the current platform.
Module disappears after seemingly successful install
Cause: A dependent module is also incompatible, and the dependency graph cascade-failed both.
Fix: Run php artisan module:compat to find all incompatibilities at once. Fix the root cause (usually a single module with the wrong constraint).
Pipeline's release-cli job panics on ARM64
Cause: Pre-fix template. The fix is in commit 93ab80d on feat/module-versioning.
Fix: Make sure feat/module-versioning (or the merged main) is what the mirror's .gitlab-ci.yml references.
Future Work
| Item | Why | Effort |
|---|---|---|
PVC-mounted /app/modules |
True atomic swap, eliminates overlay EXDEV workaround | Small (k8s manifest change) |
Widen module_versions.content_hash to varchar(80) |
Store the sha256: prefix natively, no stripping |
Migration + 2-line code change |
| Multi-version concurrent install | Tenants on different versions of the same module | Significant — needs per-tenant version pinning, namespace isolation |
| Public module registry | Third-party developers publish modules to a public catalog | Significant — auth, moderation, trust layer |
module:diff old@new |
Show what changed between two versions of a module | Small — git log between tags |
Auto-module:lock on commit (pre-commit hook) |
Prevent the most common CI failure | Tiny — git hook + symlink |
Roll-forward: module:upgrade --all from lock file |
Apply a whole lock file in one command | Medium — need transactional ordering |
Migration auto-rollback on module:rollback |
Reverse schema changes when rolling back | Hard — Laravel migrations aren't always reversible |