ADR-014: gitops-app composition spec
Status: Accepted Date: 2026-05-22
Context
Section titled “Context”PRD §6 B5 (“compose agent”) and B6 (“cross-repo promote”) describe what the agents do but not the concrete manifest layout they read and write. Without that schema, agents can’t be implemented and the gitops-app Copier template can’t be scaffolded.
This ADR resolves three coupled gaps surfaced in end-to-end-scenario.md Q13, Q14, Q15.
Decision
Section titled “Decision”A gitops-app repo has this layout:
<gitops-app-repo>/├── applications/<app-name>/│ ├── applicationset.yaml # one ApplicationSet per environment, List generator│ ├── services.yaml # human-edited registry of component services│ └── overlays/│ ├── dev/│ │ ├── kustomization.yaml # bases + image tag pins│ │ └── images.yaml # per-service image: latest│ ├── staging/│ │ └── … (sha-<short> or semver pins, edited by /gitops:promote)│ └── prod/│ └── … (semver pins, edited by /gitops:promote)├── docs/plan/ # multi-repo plans written by /app:build-feature└── .copier-answers.ymlservices.yaml schema
Section titled “services.yaml schema”services: - name: taskboard-api repo: ika100/taskboard-api shape: service-python path: k8s/base # path in service repo to use as Kustomize base - name: taskboard-web repo: ika100/taskboard-web shape: web-nextjs path: k8s/baseThe compose agent edits this file; the ApplicationSet’s List generator reads it (via a small in-repo script or a yq-rendered template).
ApplicationSet generator — List generator from services.yaml
Section titled “ApplicationSet generator — List generator from services.yaml”One ApplicationSet per environment, parameterised by services.yaml:
apiVersion: argoproj.io/v1alpha1kind: ApplicationSetmetadata: name: <app-name>-devspec: generators: - list: elements: | # rendered from services.yaml at PR time - name: taskboard-api repoURL: https://github.com/ika100/taskboard-api … template: metadata: name: '{{name}}-dev' spec: source: repoURL: https://github.com/ika100/<gitops-app-repo> path: applications/<app-name>/overlays/dev …List generator chosen (over Git directory or SCM Provider generators) because it makes the service set explicit and reviewable in services.yaml — every add/remove is a visible diff. Implicit discovery patterns are more magical but harder to audit.
dev overlay pin policy — latest (track-main mode)
Section titled “dev overlay pin policy — latest (track-main mode)”The dev overlay’s images.yaml pins every service’s image to latest. ArgoCD’s image-updater (or Argo’s normal reconcile loop with imagePullPolicy: Always) picks up new images as they’re pushed on every merge to main — dev tracks main automatically.
staging and prod overlays pin to specific sha-<short> (staging) and semver (v1.2.3, prod) tags, edited only by /gitops:promote PRs.
.platform-app.yml in service repos (for B6)
Section titled “.platform-app.yml in service repos (for B6)”Service repos that belong to a gitops-app drop a .platform-app.yml at repo root:
gitops_apps: - ika100/taskboard-appgitops_apps is a list — a service can belong to multiple apps (e.g., a shared identity service used by two products). /gitops:promote invoked from a service repo reads this file to resolve the target gitops-app. If multiple are listed, the agent prompts the user to pick.
The Copier template adds an optional --app <org>/<repo> flag to /shared:new-service that pre-populates .platform-app.yml. Users can add or edit it later by hand.
compose remove
Section titled “compose remove”Symmetric to compose add: removes the service from services.yaml, opens a PR. After merge, ArgoCD prunes the Application (if syncPolicy.automated.prune: true is set on the ApplicationSet — which the template defaults to true).
Batch operations
Section titled “Batch operations”compose and promote accept multiple services in one invocation and open one PR per logical operation, not one per service:
/gitops:compose add <svc1> <svc2> ...— single PR adding all listed services toservices.yamland the ApplicationSet./gitops:compose remove <svc1> <svc2> ...— single PR removing all listed services./gitops:promote <svc1> <svc2> ... <from> <to>— single PR pinning the given services in the target overlay./gitops:promote --all <from> <to>— single PR promoting every service currently inservices.yamlone env step.
Batch mode is the default ergonomic for multi-service products; single-service invocations are just n=1 of the batch path.
Rationale
Section titled “Rationale”- Explicit beats implicit.
services.yamlis human-readable; reviewers can see the set of components at a glance. Git directory and SCM Provider generators are less reviewable. latestin dev matches developer expectation. “Merge to main, see it in dev” is the velocity contract; pinning sha in dev breaks that loop..platform-app.ymlis grep-able and supports multi-app membership. Reusing.copier-answers.ymlfor this would conflate shape-detection with operational config and hide the membership behind a key most users don’t read.
Consequences
Section titled “Consequences”- The
gitops-appCopier template ships all of the above scaffolding:applicationset.yamltemplates,services.yamlskeleton, three overlay directories with starterkustomization.yaml, and the README that explains the layout. - The
composeagent has a tight, parseable schema to edit (services.yamlis YAML, not freeform Markdown). - The
promoteagent readsservices.yamlto validate that the service being promoted is actually a member of the app, then edits the relevant overlay’simages.yaml. /shared:new-servicegains an optional--app <org>/<repo>flag; un-flagged service repos work fine, they just need a manual.platform-app.ymledit later if they ever join an app.
References
Section titled “References”- platform-vision.md §6 B5, B6
- ADR-006: gitops-app overlay structure — env-only — env-only overlay decision this ADR concretises
- end-to-end-scenario.md Q13, Q14, Q15
Amendment (implementation, phase 3)
Section titled “Amendment (implementation, phase 3)”The shipped gitops-app template differs from the sketch above in two places, both to make the ApplicationSet actually work:
-
Overlays are per service:
overlays/<env>/<service>/kustomization.yaml(remote basehttps://github.com/<repo>//<path>?ref=<ref>+images[].newTag), with no separateimages.yaml. The List-generator ApplicationSet creates one Application per service whosepathis that directory; a single flat overlay per env would have made every Application render every service./gitops:promoteedits the pin (?ref=andnewTagtogether) in this file. -
Generated, not hand-written:
applicationset.yamland the overlay kustomizations are rendered fromservices.yamlbyscripts/render.py(devbox run render). Existing pins are preserved on re-render andrender --checkis part ofdevbox run quality. A new service starts atmain/latestin every env until promoted. -
Root Application (app-of-apps): the template also generates
bootstrap/<app>-root.yaml, an ArgoApplicationthat watchesapplications/<app>/applicationset.yaml(directory.include, automated prune + self-heal). A human applies it once per cluster withdevbox run bootstrap; afterwards ApplicationSet changes (compose add/remove) are deployed by merged PRs, so nokubectl applyis needed in the steady state (PRD A2: “Argo reconciles with no manual edits”). It lives outsideapplications/<app>/so it never manages itself, and carries no finalizer, so deleting it leaves the ApplicationSets and workloads in place. Generated and drift-checked byscripts/render.py, validated by kubeconform against the Argo CRD schemas.
Superseded in part by ADR-017: The GitOps repo owns every Kubernetes manifest; services ship an image: services no longer provide a Kustomize base; the gitops repo generates the manifests from
services.yaml.