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_URL in CI/CD settings before the first live tag. Next.js inlines NEXT_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: false in /etc/docker/daemon.json, or pushes fail with MANIFEST_BLOB_UNKNOWN — the containerd image store emits OCI manifests this registry rejects.

Adding CI to a New Module

  1. Create .gitlab-ci.yml in the module directory:

    include:
      - project: 'commstate/main'
        file: 'ci/templates/php-module.yml'
        ref: main
    
  2. Add the module to scripts/sync-to-gitlab.sh split list

  3. 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"
    
  4. Run bash scripts/sync-to-gitlab.sh to push