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_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