Shape: Multi-Repo / Per-Service
In a multi-repo layout, each service is its own git repository — a
backend repo, a frontend repo, a desktop repo, a
marketing-site repo — each cloned, built, tested, and deployed independently. The
shared GAIA project config lives above them at a non-git project root that ties the
services together for planning, while each service repo carries only the slice of config its own CI
needs.
This is distinct from Shape: Microservices (many services in one monorepo) and from the single-clone "config above a published sub-tree" case. Here the config is in no service clone at all.
When this shape applies
- Each deployable service is a separate GitHub repository, deployed on its own cadence.
- A shared project root (often not itself a git work tree) holds the planning artifacts and the
central
.gaia/config/project-config.yaml. - You want each service repo's CI to run only that service's affected tests and deploy only that service — the benefits of selective test execution and per-component deploy, applied per repository.
The config-visibility problem
Every generated GAIA workflow resolves the project config by looking at the checkout root, then
CLAUDE_PROJECT_ROOT, then walking up from the working directory. In a multi-repo
layout this finds nothing: a service repo's CI clones only that service repo, so there
is no parent directory in the runner to walk up into, and CLAUDE_PROJECT_ROOT points at a
path that does not exist on the runner. With no config found, the workflow takes the
"no config → do everything" branch — the full test suite runs and the whole service redeploys on
every change, defeating selective tests and per-component deploy for the entire multi-repo project.
The fix is to project a minimal, self-contained config slice into each service repo,
checked in at its own .gaia/config/project-config.yaml, so the workflow resolves it at the
service repo's own checkout root.
Central config with per-service repositories
Declare each stack's owning repository with stacks[].repository (in
owner/repo form). Stacks without a repository are assumed to live alongside
the central config (the single-repo default).
# <project-root>/.gaia/config/project-config.yaml (the central config)
project_name: acme-platform
platforms: [web]
ci_cd:
promotion_chain:
- { id: staging, branch: staging }
- { id: main, branch: main }
release:
strategy: conventional-commits
version_files: [backend/package.json, frontend/package.json]
environments:
- { id: production, branch: main, kind: deploy }
stacks:
- name: backend
language: typescript
paths: ["backend/**"]
repository: acme/backend
cross_refs: ["shared-lib"]
- name: frontend
language: typescript
paths: ["frontend/**"]
repository: acme/frontend
cross_refs: ["shared-lib"]
- name: shared-lib
language: typescript
paths: ["shared/**"]
repository: acme/shared-lib
- name: marketing
language: html
paths: ["marketing/**"]
repository: acme/marketing
Projecting per-service slices
From the project root, project a slice for each service repo:
# Drive it via the config-CI skill (recommended):
/gaia-config-ci --project-slice acme/backend --out ../backend/.gaia/config/project-config.yaml
# …or call the deterministic script directly:
plugins/gaia/scripts/project-config-slice.sh \
--config .gaia/config/project-config.yaml \
--service acme/backend \
--out ../backend/.gaia/config/project-config.yaml
--service accepts an owner/repo value (matched against
stacks[].repository) or a stack name. Commit the generated slice into the
service repo so its CI resolves it at the service's checkout root.
What a slice contains
The slice is the minimal self-contained config that service needs — not the whole
multi-service config. For acme/backend above (which cross_refs
shared-lib):
- the service's own
stacks[]entry, plus the transitivecross_refsclosure (soshared-libis carried along, and within-service transitive narrowing still works); ci_cd.promotion_chainverbatim — so thestaging→mainpromotion-push full-suite rail still fires in the service repo;release(version_files) — so version-bump still resolves;environments,platforms, andproject_name.
Unrelated services (frontend, marketing) are excluded. Each slice carries a
DO NOT EDIT BY HAND header naming its source and the regenerate command.
Keeping slices in sync
The projection is idempotent: whenever the central config changes (a new stack, a
changed promotion chain, a new cross-ref), re-run --project-slice for each affected
service and commit the refreshed slice. A simple project-root script can loop over every
stacks[].repository and regenerate all slices in one pass.
Cross-repo affected-set / deploy (current scope). Within a service repo, selective
tests narrow to that service's changed files and the carried cross_refs closure. Full
cross-repo affected-set hand-off (one service's change triggering a dependent service's pipeline) and
cross-repo deploy ordering are tracked as follow-on work — the slice declares the cross-repo
dependencies so they are visible, and the promotion-push rail still guarantees a full suite at the
final-tier gate.
You'll know it worked when
- Each service repo has a checked-in
.gaia/config/project-config.yamlcontaining only its own stack(s) + cross-ref closure. - A feature PR in the
backendrepo runs the backend's affected tests — not a blanket full suite — and the promotion PR to the final tier still escalates to the full suite. - Re-running
--project-sliceafter editing the central config produces a byte-identical slice when nothing relevant changed, and an updated slice when it did.