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 pushes to main build arm64 only and tag :$CI_COMMIT_SHORT_SHA
plus :latest. That serves UAT, which runs on arm64.
Live runs on x86_64, so a release has to produce both architectures:
git tag v1.2.3 && git push --tags
Every deployable commit — a merge to main, a release/* branch, or a semver
tag — triggers ci/templates/images.yml, which builds each image natively on
both runners and publishes them under one tag as a manifest list. Each node
then pulls the variant matching itself, so the Kubernetes manifests stay
identical across environments.
Multi-arch is no longer tag-only. It used to be, which meant live could only
ever ship from a tag while main produced arm64 images UAT alone could run.
Building both on every deployable commit is what makes one-click promotion
from main to live possible. See Release Flow
for the full route.
Native builds on two runners, 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.
The manifest stage asserts each tag really contains both architectures. A single-arch manifest deploys cleanly to UAT and fails only on the live node, which is the worst place to discover it.
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