GitLab CI/CD & Repository Structure
GitLab (gitlab.wexron.io) is the primary monorepo. It hosts the canonical
commstate/main repository, runs every pipeline, and holds the container
registry that both clusters pull from.
The split repositories under commstate/core, commstate/modules and so on are
read-only CI mirrors. They exist so a module can be consumed independently;
they are not places to work. A merge request opened against a mirror cannot be
merged — target commstate/main.
A GitHub remote named origin still exists in some working copies. It is a
legacy mirror and must not be pushed to. Push to gitlab-main.
Repository Structure
The monorepo is split into 31 GitLab projects under the commstate group:
commstate/
├── main # Full monorepo mirror
├── core/
│ ├── frontend # Next.js web app
│ ├── backend # Laravel API
│ ├── mobile-app # Expo React Native app
│ └── docs # Documentation site
├── services/
│ └── ai-agent # PydanticAI Python service
├── modules/ # 17 HMVC modules
│ ├── core, orders, products, customers, wms, ai,
│ │ workflows, communications, channel-livechat,
│ │ store-shopify, mobile-app-module
│ ├── reseller-admin, reseller-network,
│ │ reseller-catalog, reseller-orders, reseller-finance
│ └── core-dashboard
├── themes/ # 5 UI themes
│ ├── theme-shadcn, theme-modern, theme-luma,
│ │ theme-harshita, theme-solace
└── infra/ # Infrastructure configs
├── k8s, docker, nginx
Syncing to GitLab
The split runs automatically in CI. The module-split job on main
executes ci/scripts/split-and-mirror.sh, which git subtree splits each
directory and force-pushes it to the matching mirror.
scripts/sync-to-gitlab.sh does the same thing by hand and is only needed when
recovering a mirror outside a pipeline.
Mirrors receive the source branch, not main. GitLab CE cannot grant
per-user push to a protected main, so main on each mirror stays empty and
consumers pin a tag.
A brand-new mirror rejects its first push
GitLab refuses a non-default branch into an empty repository:
remote: A default branch (e.g. main) does not yet exist for commstate/modules/<name>
! [remote rejected] split/<Name> -> source (pre-receive hook declined)
Create any commit on main in that project first — a README is enough — and
re-run the job.
Releases
Day-to-day merges to main build amd64 and tag :$CI_COMMIT_SHORT_SHA
plus :latest. That serves UAT on lander, and the same images promote
straight to live — both hosts are x86_64.
Cutting a named version pins the images by tag rather than by commit:
git tag v1.2.3 && git push --tags
Every deployable ref builds every image at one tag, for exactly one architecture — the one its target cluster runs:
| Ref | Job | Arch | Cluster |
|---|---|---|---|
develop |
build:arm64 |
arm64 | endurance (dev) |
main |
build:amd64 |
amd64 | lander (uat) |
release/*, v1.2.3 |
build:amd64 |
amd64 | ranger (live) |
There is no manifest list any more. There used to be, because UAT was arm64
and live was amd64, so anything that could reach live had to be published
covering both — twelve builds per merge instead of six. UAT has since moved
to lander, which is amd64 like live, leaving endurance as the only arm64
machine and develop as the only branch that needs to build for it.
Native builds on each architecture, not one buildx --platform job:
emulating the foreign architecture through QEMU costs roughly 5-10x on a
Composer install plus a Next.js build, and this is the release path.
develop publishes dev-<sha> rather than a bare <sha>. develop is cut
from main, so the two can briefly name the same commit — and with a bare
sha the two architectures would overwrite each other's tag, leaving a
cluster pulling an image it cannot start. See
Release Flow for the full route.
Set
RELEASE_API_URLin CI/CD settings before the first live tag. Next.js inlinesNEXT_PUBLIC_*at build time, so without it the release bundle carries the wrong API URL as its baked fallback.
CI Pipeline Templates
Shared templates live in ci/templates/ in the monorepo. Each split repo includes its template via:
# .gitlab-ci.yml in each module
include:
- project: 'commstate/main'
file: 'ci/templates/php-module.yml'
ref: main
PHP Module Pipeline (22 modules)
| Job | Stage | Blocking? | What it does |
|---|---|---|---|
php-lint |
lint | Yes | Syntax check all PHP files |
php-code-style |
lint | Advisory | PSR-12 check via PHP CS Fixer |
module-json-check |
lint | Yes | Validates required fields in module.json |
frontend-scan |
lint | Advisory | Counts TypeScript files |
php-static-analysis |
analyze | Advisory | PHPStan level 1 |
code-metrics |
quality | Advisory | Lines, files, classes, functions → metrics.json artifact |
Python Service Pipeline (AI agent)
| Job | Stage | Blocking? |
|---|---|---|
ruff-lint |
lint | Advisory |
type-check |
lint | Advisory (mypy) |
pytest |
test | Yes (with coverage) |
code-metrics |
quality | Advisory |
dependency-audit |
quality | Advisory (pip-audit) |
Frontend Pipeline
| Job | Stage | Blocking? |
|---|---|---|
eslint |
lint | Advisory |
typecheck |
lint | Advisory (needs full monorepo for @modules/) |
unit-tests |
test | Advisory (Vitest) |
build |
build | Yes (bun run build) |
Backend Pipeline
| Job | Stage | Blocking? |
|---|---|---|
php-lint |
lint | Yes |
pest-tests |
test | Advisory (needs full monorepo context) |
composer-audit |
quality | Advisory |
Status Dashboard
A GitLab Pages dashboard shows pipeline status across all 31 repos:
URL: status-e61e2f.wexron.app
Features:
- Pipeline status (pass/fail/running) per project
- Job-level colored dots
- History sparkline (last 8 runs)
- Coverage percentages
- Code metrics (files, lines, classes, functions)
- Recent commits across all repos
- Top failing jobs
- Language breakdown
- Active contributors
Auto-updates every 15 minutes via scheduled pipeline. Can also be triggered manually.
Runner Configuration
Two runners, one per architecture:
| runner | host | tags | untagged jobs |
|---|---|---|---|
wexron-docker-runner |
endurance | docker, linux, arm64 |
yes |
ranger-amd64-builder |
ranger | docker, linux, amd64 |
no |
Both use the Docker executor with the host socket mounted, concurrent = 4.
The amd64 runner is registered with run_untagged=false on purpose. Every job
that predates it is untagged or arm64-tagged; without that flag the new runner
would start picking them up at random and arm64 work would land on an x86 box.
It stays idle until a job explicitly asks for amd64.
A host running Docker 29 as a build runner needs
features.containerd-snapshotter: falsein/etc/docker/daemon.json, or pushes fail withMANIFEST_BLOB_UNKNOWN— the containerd image store emits OCI manifests this registry rejects.
Adding CI to a New Module
-
Create
.gitlab-ci.ymlin the module directory:include: - project: 'commstate/main' file: 'ci/templates/php-module.yml' ref: main -
Add the module to
scripts/sync-to-gitlab.shsplit list -
Create the GitLab project:
curl -s --header "PRIVATE-TOKEN: $TOKEN" "$GL/projects" \ --data-urlencode "name=Module Name" \ --data-urlencode "path=module-path" \ --data-urlencode "namespace_id=$MODULES_GID" -
Run
bash scripts/sync-to-gitlab.shto push