Skip to content

ADR-012: CI Composition Across Mono-Repo Artifacts

Field Value
Date 2026-07-23
Status Accepted
Deciders Holger Zahnleiter
Supersedes
Superseded by ADR-014 (changelog naming/path only), ADR-015 (Pages aggregation only), ADR-016 (trigger-job tag gating only) — rest still in force

Context and Problem Statement

ADR-011 generates each .mono-repo.yaml entry exactly as repo init would generate it standalone — including its own full CI pipeline (.gitlab-ci.yml and/or .github/workflows/*.yml), per the trigger-tiered design in ADR-006 (Pre-Check → Build → Test → Package & Publish/Deploy, MR/Merge-to-main/Tag tiers). A mono-repo with N artifacts therefore ends up with N independently generated CI files that need to compose into one working CI setup for the repository as a whole, and a way to avoid running artifact B's pipeline when only artifact A changed.

Inspection of a representative template (internal/templating/go/files/service/_gitlab-ci.yml.tmpl) and its 8 GitLab siblings (go/{library,cli_tool}, java/{library,service}, cpp/{library,cli_tool,service}, typescript/react_frontend) shows the ~15 job names each generates (commit-lint, scan, arch-test, lint, test, coverage, build, changelog, release, pages, push-image, scan-image, promote-image, scan-release-image, plus a few project-type-specific additions) repeat byte-for-byte identical across every language/project-type template. Two further collisions: the Git tag pattern is bare v[0-9]+\.[0-9]+\.[0-9]+ with no artifact prefix (internal/templating/general/files/cliff.toml.tmpl:48, mirrored in every CI template's tag gate), and the registry push target ($CI_REGISTRY_IMAGE / ghcr.io/{{ github.repository }}, e.g. internal/templating/go/files/service/_gitlab-ci.yml.tmpl:186-210) is repo-scoped, not artifact-scoped. CHANGELOG.md is written to one hardcoded root path. No rules: changes: (or GitHub paths:) exists in any template today, so "only build what changed" is unsolved even for a single artifact currently. GitHub Actions templates exist only for C++ (internal/templating/cpp/files/{service,library,cli_tool}/_github/workflows/ci.yml.tmpl) — Go, Java, and TypeScript have none, a pre-existing gap independent of this decision.

The user pointed at /Users/holgerzahnleiter/projekte/eigene/build-images/.gitlab-ci.yml — a different repository of theirs that already builds/publishes several independently-versioned container images from one repo — as prior art: it scopes each image's build/scan jobs with rules: changes: on that image's own Dockerfile, and namespaces releases per image (<image>-vX.Y.Z tags, CHANGELOG-<image>.md), with validate-tag failing fast on an unrecognized tag pattern.

Correction discovered during implementation (initially missed by this ADR): GitLab parent-child pipelines (the chosen composition mechanism, see below) still share one Git checkout$CI_PROJECT_DIR is the mono-repo root for every job in every triggered child pipeline, never the artifact's own subdirectory. This ADR originally claimed each per-artifact .gitlab-ci.yml stays "exactly today's existing single-repo template, unmodified" when reused as a child pipeline. That is only true for job structure (names, stages, dependency graph). It is false for every path-bearing value inside a job: artifacts: paths: entries (build/{{.TechnicalArtifactName}}, test_reports/...), Kaniko's --context/--dockerfile, the trivy fs secret/vuln scan target (.), and the make <target> invocations themselves all resolve relative to $CI_PROJECT_DIR — the mono-repo root — regardless of which artifact "owns" the job. Left unmodified, every mono-repo child's pipeline would build, scan, and collect artifacts from the wrong location (the repo root instead of its own subdirectory). This is corrected below; the fix is template-driven parameterization, not a change to job structure.

A second gap found during implementation: GitLab Pages publishes one site per project. If every entry's child pipeline generates its own pages job, each one's pages deployment replaces the previous one's — N artifacts' docs sites cannot coexist by each independently running the existing per-artifact pages job unmodified. Aggregating N docs sites into one combined Pages deployment is a distinct, larger piece of design (which sources feed one combined site, under what URL structure) that this ADR does not attempt to solve. The safe, scoped fix adopted here: suppress the pages job entirely for mono-repo children (see below); a combined mono-repo documentation site is explicitly deferred as a separate follow-up.

Superseded by ADR-015: the combined mono-repo documentation site deferred here has since been built (mkdocs-monorepo-plugin combining each artifact's unmodified mkdocs.yml into one root build) — see ADR-015 for the design and why. Per-artifact pages jobs remain suppressed exactly as decided here; only a new root-level pages job (not any per-artifact one) now publishes.

Decision Drivers

  • Keep changes to the 9 existing, already golden-tested per-language GitLab CI templates and the 1 existing GitHub template minimal and structural-neutral: parameterize path-bearing values in place rather than restructuring jobs, renaming them, or duplicating templates.
  • Solve "an unchanged artifact's pipeline must not run" for both GitLab and GitHub.
  • Generalize across both CI providers without forcing GitHub into a GitLab-shaped mechanism it doesn't have (or vice versa) — ADR-006 already establishes that CI-provider-specific mechanics belong in the templates, not dictated uniformly by the ADR layer.
  • Reuse Git tag/changelog namespacing prior art the user already has running in production (build-images), rather than inventing a new convention.
  • Standalone (non-mono) generation must stay byte-for-byte unchanged — every new parameter defaults to today's behavior.

Considered Options

GitLab:

  1. Parent-child pipelines — root .gitlab-ci.yml (new, mono-repo-only) with one small trigger job per manifest entry, gated by rules: changes: on that entry's subdirectory, firing trigger: include: local: at the entry's own untouched .gitlab-ci.yml as an isolated child pipeline. (chosen)
  2. Merged flat pipeline — root .gitlab-ci.yml uses plain include: local: to pull every entry's jobs into one shared pipeline (mirrors build-images' own flat structure), requiring every job name in all 9 language templates to be prefixed per artifact to avoid collision.

GitHub:

  1. One workflow file per manifest entry, path-filtered on push/pull_request, tag-name- filtered (not path-filtered) on the release trigger. (chosen — no real alternative: GitHub has no merge-multiple-workflows-into-one-run mechanism comparable to GitLab's include:, so per-entry files are the only fit for this repo's per-language template structure)

Decision Outcome

Chosen: Option 1 (GitLab parent-child pipelines) + Option 3 (GitHub per-entry workflow files), plus two small additive per-artifact template parameters used by both providers.

GitLab: parent-child pipelines

The mono-repo root gets a new .gitlab-ci.yml (generated only for mono-repos) containing one trigger job per manifest entry:

trigger-flight-booking:
  rules:
    - changes: ["flight-booking/**"]
  trigger:
    include: "flight-booking/.gitlab-ci.yml"
    strategy: depend

Each per-artifact .gitlab-ci.yml keeps exactly today's existing job structure — names, stages, needs: dependency graph — a triggered child pipeline gets its own isolated job namespace and its own stages:, so nothing above needs renaming. An entry whose subdirectory didn't change simply never triggers its child pipeline — no wasted compute. strategy: depend makes the parent pipeline's status reflect the child's, matching how a single pipeline's status works today.

What does change, inside every job's path-bearing values (see the correction above): every make <target> invocation, every artifacts: paths: entry under build//test_reports/, the trivy fs scan target, and Kaniko's --context/--dockerfile all become template-parameterized so they resolve inside the artifact's own subdirectory when generating a mono-repo child, and render byte-identical to today's output when generating a standalone repo. The pages job is wrapped in a template conditional and omitted entirely for mono-repo children (see the Pages gap above).

Rejected Option 2 (merged flat include:) because it would require renaming every one of ~15 job names across all 9 existing templates (testflight-booking:test, etc.) purely to avoid collision — a much larger, riskier diff than adding a handful of trigger jobs, for an outcome parent-child pipelines already provide natively.

GitHub: one workflow file per entry

One .github/workflows/<artifact.name>.yml per manifest entry — already fully independent workflow runs, so there is no shared job namespace to collide in the first place. push/ pull_request triggers get paths: ['<artifact.name>/**'] (the tiers that actually rebuild). The tag trigger is not path-filtered — GitHub's paths: filter has a documented gotcha for tag pushes (tagging an already-existing commit, the normal release flow, often carries no new commits for GitHub to diff against, making path-filter behavior on tag refs unreliable) — and it doesn't need to be, since per ADR-006 the tag/release tier never rebuilds anything (it only promotes an already Release-tested artifact); it's already uniquely scoped by the artifact-prefixed tag pattern itself (<artifact.name>-v*.*.*). Exact trigger YAML here is a documentation-time approximation, to be verified against current GitHub Actions documentation at implementation time — consistent with how ADR-006 already defers provider-specific mechanics to the templates rather than asserting them at the ADR level.

Separate, pre-existing gap noted but explicitly out of scope for this decision: GitHub Actions templates exist only for C++ today; Go, Java, and TypeScript have none. Confirmed with the user this is not addressed here — closing it is an independent, larger undertaking.

Additive per-artifact template parameters (both providers)

All default to today's unprefixed/unscoped behavior when generating a standalone (non-mono) repo — zero change to existing output. Implemented as new internal.ProjectDescriptor. IsMonoRepoChild bool (set only by initFromMonoRepoConfig) plus derived model.PresentationModel fields computed from it:

  • Registry image path (MonoRepoRegistrySuffix): mono-repo children append the artifact name — $CI_REGISTRY_IMAGE/<artifact.name> (GitLab), ghcr.io/<repo>/<artifact.name> (GitHub).
  • Tag namespace (MonoRepoTagPrefix): mono-repo children use <artifact.name>-vX.Y.Z instead of bare vX.Y.Z — applied everywhere a job matches or strips the tag pattern (cliff.toml's tag_pattern, every $CI_COMMIT_TAG =~ /^...$/ gate, and ${CI_COMMIT_TAG#v}, which must strip the whole <artifact.name>-v prefix, not just v, or it silently no-ops for mono-repo children since their tags don't start with a bare v).
  • Changelog namespace (MonoRepoChangelogFileName, MonoRepoIncludePathArg): mono-repo children write CHANGELOG-<artifact.name>.md instead of CHANGELOG.md, and scope git cliff with --include-path <artifact.name>/** so each artifact's changelog reflects only its own commits — both mirroring build-images' own convention exactly. RELEASE_NOTES.md is deliberately left unscoped: it's an ephemeral, never-committed, single-pipeline-run artifact consumed immediately by that same run's release: job, so it cannot collide across artifacts' separate child pipeline runs. Superseded by ADR-014: the filename half of this bullet no longer reflects the current behavior — see ADR-014 for the revised decision and why. The --include-path scoping and the RELEASE_NOTES.md reasoning above are unaffected and still in force.
  • Working-directory scoping (inline {{if .IsMonoRepoChild}}...{{end}} at each call site, not a single precomputed field, so standalone output stays exactly byte-identical): every make <target> invocation gains a conditional -C <artifact.name>; every artifacts: paths: entry under build//test_reports/ gains a conditional <artifact.name>/ prefix; the trivy fs secret/vuln scan target becomes <artifact.name> instead of .; Kaniko's --context/--dockerfile gain the same conditional prefix.

specs/guidelines/git.md (the guideline template copied into every generated project) gets a short conditional clause documenting the <artifact-name>-vX.Y.Z tag format for mono-repo children, alongside the existing plain vX.Y.Z rule for standalone repos.

Positive Consequences

  • Zero job-renaming surgery across the 9 existing GitLab templates or the 1 existing GitHub template — each keeps generating exactly what it generates today.
  • "Unchanged artifact doesn't rebuild" is solved natively by GitLab's own change-detection (rules: changes: on the trigger job) and by GitHub's paths: filter — no custom diff-computation logic to build or maintain.
  • Git tag/changelog namespacing directly reuses a convention already proven in production (build-images), rather than inventing a new one.
  • Provider-specific mechanics (parent-child pipelines vs. per-entry workflow files) stay confined to their respective templates, consistent with ADR-006's existing precedent.

Negative Consequences / Risks

  • GitLab pipeline views show nested child pipelines rather than one flat job list — a small UX change from a single-repo pipeline's view, accepted as the cost of avoiding template surgery.
  • The GitHub tag-trigger/path-filter interaction is a known, occasionally-changing edge case in GitHub's own product; the exact trigger YAML must be verified against current documentation when Step 3 implements this, not assumed from this ADR.
  • CI committing a regenerated changelog file back to a protected main branch already carries a small race-condition risk for any single repo's tag pipeline today (pre-existing, accepted). A mono-repo doesn't introduce a new kind of risk here, only slightly more frequent exposure to the same one, since N artifacts can each cut a release independently.
  • Mono-repo children get no GitLab Pages site at all in this pass (the pages job is suppressed entirely) — a combined, multi-artifact documentation site is deferred as a separate follow-up, not solved by this ADR. Superseded by ADR-015: this is now solved by a new root-level pages job; per-artifact suppression stays exactly as decided here.
  • The trigger-<artifact> jobs decided here are gated by changes: alone. Superseded by ADR-016: a changes: rule always evaluates to true in a tag pipeline, so any artifact's release tag fired every child pipeline and all but one failed with "no jobs". Each trigger job now matches its own tag namespace explicitly and opts out of foreign tag pipelines.

Pros and Cons of the Options

GitLab Option 1 — Parent-child pipelines (chosen)

  • Pro: no changes to existing job names/structure in any language template.
  • Pro: native GitLab feature purpose-built for this (isolated job namespace, own stages: per child).
  • Con: nested pipeline view instead of one flat job list.

GitLab Option 2 — Merged flat pipeline with job-name prefixing

  • Pro: single flat pipeline view, closer to build-images' own structure.
  • Con: every job name in all 9 templates needs prefixing to avoid collision — the largest, riskiest diff among the options considered, for a purely cosmetic benefit over Option 1.

GitHub Option 3 — Per-entry workflow files (chosen)

  • Pro: no shared job namespace, so no collision risk by construction.
  • Pro: matches how GitHub Actions is normally used for monorepos (no native merge-into-one-run primitive exists).
  • Con: N separate workflow files to reason about instead of one, though each is independent and self-contained.
  • specs/architecture/ADRs/ADR-006-ci_pipeline_design.md — the per-artifact pipeline design this decision composes N copies of.
  • specs/architecture/ADRs/ADR-011-mono_repo_manifest.md — the manifest and per-artifact generation this builds on.
  • /Users/holgerzahnleiter/projekte/eigene/build-images/.gitlab-ci.yml — prior art for per-artifact rules: changes: gating and <name>-vX.Y.Z tag/changelog namespacing.
  • internal/templating/go/files/service/_gitlab-ci.yml.tmpl — representative GitLab template whose job names were checked for collisions across all 9 language/project-type templates.
  • internal/templating/general/files/cliff.toml.tmpltag_pattern, to be parameterized with the artifact-name prefix for mono-repo children.
  • internal/templating/cpp/files/service/_github/workflows/ci.yml.tmpl — the only existing GitHub Actions template today.
  • specs/guidelines/git.md — tag format rule to gain a mono-repo-child conditional clause.
  • specs/domain/use_cases/UC005-init_mono_repo.md, specs/domain/features/UC005-init_mono_repo.feature — behavior specification.