Skip to content

ADR-015: Combined GitLab Pages Documentation Site for Mono-Repos

Field Value
Date 2026-07-28
Status Accepted
Deciders Holger Zahnleiter
Supersedes ADR-012 (Pages-aggregation gap only)
Superseded by ADR-016 (gating decision only) — rest still in force

Context and Problem Statement

ADR-012 deliberately deferred combined GitLab Pages documentation for mono-repos: GitLab allows only one Pages site per project, so every mono-repo child's own pages job is suppressed entirely today ({{if not .IsMonoRepoChild}} in all 9 per-language CI templates), and no artifact's docs are published at all. Reviewing the demo mono-repo surfaced the natural follow-up question: can several artifacts' independently generated mkdocs.yml/specs/ trees be combined into one navigable site, with each artifact appearing as a real top-level nav tab (not just a link on a landing page)?

$CI_PROJECT_DIR is already the mono-repo root for every job, including triggered child pipelines (ADR-012's own correction) — so a combined build does not need GitLab's cross-pipeline artifact-fetching mechanics at all. A single job in the root .gitlab-ci.yml, which today only contains per-artifact trigger jobs, can build directly from the full checkout: the root's own ADR-013 specs scaffold plus every artifact's own, completely unmodified mkdocs.yml.

Decision Drivers

  • Real top-level nav tabs per artifact (not a links-only landing page) were the user's explicit requirement, not just "some way to reach every artifact's docs from one place."
  • No per-artifact mkdocs.yml should need modification — each keeps generating exactly what it generates today, standalone or combined.
  • Minimize the blast radius of a first attempt: prove the approach works with a local build before making any permanent, harder-to-reverse change (a new baked Docker image version).
  • Stay honest about where this deliberately diverges from existing conventions elsewhere in this generator, rather than let the divergence go undocumented.

Considered Options

  1. mkdocs-monorepo-plugin (!include nav directive) — root mkdocs.yml includes each artifact's own mkdocs.yml unmodified; one combined mkdocs build produces real top-level tabs via mkdocs-material's navigation.tabs feature. (chosen)
  2. Root landing page with links — each artifact keeps its own independent, separately-built site; a root page links to each. No new plugin, no coupling between artifacts' builds, but no real tab-level navigation — an extra click, and no unified nav tree.

For the plugin's own installation:

  1. Install mkdocs-monorepo-plugin at CI-runtime (pip install), using the existing, unmodified mkdocs:1.0.0 image (with one small, independent entrypoint fix — see below). (chosen for this pass)
  2. Bake the plugin permanently into the shared mkdocs Docker image (build-images repo).

Decision Outcome

Chosen: Option 1 (mkdocs-monorepo-plugin) + Option 3 (install at CI-runtime, prove it first).

Root mkdocs.yml (new, mono-repo-only)

repo init now writes a root mkdocs.yml (WriteMonoRepoRootMkdocs, mirroring the WriteMonoRepoRootSpecs/writeMonoRepoRootGitlabCI root-file precedent from ADR-012/013). It combines the root's own 3 ADR-013 files via a static nav: (not gen-files/literate-nav — the two mechanisms don't compose: !include is a YAML directive resolved inside nav: itself, while literate-nav reads a separate SUMMARY.md; there is no way to emit a !include line from literate-nav's Markdown-bullet-list source) with one !include ./<artifact>/mkdocs.yml line per manifest entry, verbatim, unmodified:

plugins:
  - search
  - monorepo
  - mermaid2:
      javascript: assets/js/mermaid.min.js

nav:
  - Overview: index.md
  - System-Wide ADRs: architecture/ADRs/README.md
  - System-Wide Business Rules: domain/business_rules.md
  - Payment: '!include ./payment/mkdocs.yml'
  - Car Rental: '!include ./car-rental/mkdocs.yml'

Validated locally end-to-end (a scratch root mkdocs.yml against the demo repo's 6 already-real artifacts, before writing any generator code): mkdocs-monorepo-plugin==1.1.2 against mkdocs 1.6.1 builds cleanly, correctly walks into each included artifact's own docs_dir: specs and its own gen-files/literate-nav-generated pages, and produces real top-level tabs per artifact.

Root pages job (new, in root .gitlab-ci.yml)

The root .gitlab-ci.yml, previously pure trigger-dispatcher with no stages: block at all, gains an explicit stages: [trigger, docs] (every existing trigger job gets stage: trigger to avoid breaking, since they previously landed in GitLab's implicit default test stage) plus:

pages:
  stage: docs
  needs: []  # runs immediately; depends on no trigger job's child pipeline result
  image:
    name: registry.gitlab.com/hzahnlei/build-images/mkdocs:1.1.0
    entrypoint: [""]
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  script:
    - pip install --no-cache-dir mkdocs-monorepo-plugin==1.1.2
    - mkdocs build --site-dir public
  artifacts:
    paths:
      - public
    expire_in: never

needs: [] is necessary, not optional: GitLab runs stages sequentially, and every trigger job uses strategy: depend — without needs: [], the new docs stage would wait for every triggered child pipeline in that push to fully complete before starting, despite depending on none of their output.

Independent entrypoint fix (build-images repo)

Each included artifact's own mermaid2 plugin config (javascript: assets/js/mermaid.min.js, relative to its own docs_dir) needs a real file at <artifact>/specs/assets/js/mermaid.min.js. The shared mkdocs image's entrypoint previously injected mermaid.min.js only into the checkout root's specs/assets/js/. Rather than add a CI-script copy-loop (which would violate this generator's own general_coding.md guideline — "check whether the tool can read directly from the original location instead of copying a file to make it findable"), entrypoint-mkdocs.sh was extended to loop over specs */specs instead of just specs — backward-compatible (a standalone repo has no */specs, so the glob matches nothing extra). Released as mkdocs:1.1.0. No other per-artifact template or image change was needed for this: standalone repos keep working with either tag, and every mono-repo child's own pages job stays suppressed entirely (ADR-012) regardless, so no per-language template needs the new tag.

Deliberate deviations, named explicitly

  • Temporary exception to tool_chain.md's own "no installation during CI runs" / "self-contained, no internet access needed" guideline. The new pages job's pip install violates what this generator prescribes to every project it generates. Accepted deliberately and temporarily: prove the plugin combination actually works (it is officially beta — "may not yet be fully compatible with other Mkdocs configuration," per its own documentation, with a changelog history of breaking against newer mkdocs-core releases) before committing to a permanent, harder-to-reverse Docker image change. Follow-up: bake mkdocs-monorepo-plugin into the image once this has run in production for a while, removing the exception.
  • Deviation from ADR-003 (publish docs only on vX.Y.Z tags): this combined site is branch- gated, not tag-gated, because it is not a versioned release artifact of any single service — it's a living, always-current combined view. Named explicitly rather than silently picking a different trigger. Superseded by ADR-016: this deviation was reverted. The site is now built on every docs change but published only on an artifact release tag, and it carries a build date plus a table of the artifacts' released versions instead of a version of its own.
  • Known cosmetic quirk, not a defect: mkdocs-monorepo-plugin derives each included artifact's URL segment from that artifact's own site_name (verbatim if it matches ^[a-zA-Z0-9_.\-/]+$, slugified otherwise) — not from the !include path or the root nav's label. A single-word project name (e.g. "Payment") keeps its original casing (/Payment/...); a multi-word one (e.g. "Car Rental") gets slugified to lowercase-hyphenated (/car-rental/...). This is the plugin's own behavior, out of this generator's control without changing every per-artifact site_name — accepted as a minor inconsistency, not worth a workaround.
  • Pre-existing relative-link breakage, not introduced by this ADR: several specs/ files already contain relative Markdown links between sibling artifacts (e.g. ../../../car-rental/specs/domain/business_rules.md) that predate any combined build and don't resolve to the new combined URL structure. mkdocs build reports these as warnings, not failures; fixing them is a separate, pre-existing cleanup, out of scope here.

Positive Consequences

  • Real, working combined documentation site with genuine top-level nav tabs per artifact, matching the explicit requirement — validated with an actual local build, not just template review.
  • No per-artifact mkdocs.yml is modified — the same file works standalone or combined, unchanged.
  • The image change (entrypoint fix) is small, independent of the (deferred) plugin bake-in, and backward-compatible for every existing consumer of the image.

Negative Consequences / Risks

  • The whole combined build is now a single process: a broken mkdocs.yml/specs/ in any one artifact could break the combined site's build for every artifact, a coupling that didn't exist before (each mono-repo child's own docs job was fully isolated, if suppressed).
  • mkdocs-monorepo-plugin is beta software with a history of breaking against mkdocs-core upgrades; pinning helps, but future mkdocs/mkdocs-material upgrades in the shared image need to re-verify compatibility, not just bump the version blindly.
  • The pip install-at-runtime step depends on outbound internet access during that one CI job, unlike every other job in this generator's output.

Pros and Cons of the Options

Option 1 — mkdocs-monorepo-plugin (chosen)

  • Pro: real top-level tabs, matches the explicit requirement.
  • Pro: no per-artifact mkdocs.yml modification.
  • Con: beta plugin, couples all artifacts' docs builds into one process.
  • Pro: zero new dependency, builds stay fully isolated per artifact.
  • Con: no real tab-level navigation — rejected because it doesn't meet the stated requirement.

Option 3 — Install plugin at CI-runtime (chosen for this pass)

  • Pro: validates the whole approach before any permanent image change; image stays untouched except for the small, independent, backward-compatible entrypoint fix.
  • Con: temporary, explicit violation of this generator's own tool_chain.md guideline; CI-runtime internet dependency.

Option 4 — Bake into the image now

  • Pro: no guideline exception, no CI-runtime internet dependency.
  • Con: commits to a permanent image change before the plugin combination has been proven to actually work end-to-end — rejected for this first pass; planned as the follow-up once proven.
  • specs/architecture/ADRs/ADR-012-mono_repo_ci_composition.md — the Pages-aggregation gap this ADR closes; "Superseded by" amended to reference this ADR for that gap specifically.
  • specs/architecture/ADRs/ADR-013-mono_repo_root_specs_and_contracts.md — the root-file precedent (WriteMonoRepoRootSpecs) this decision's WriteMonoRepoRootMkdocs mirrors.
  • specs/architecture/ADRs/ADR-003-gitlab_pages_release_docs.md — the tag-gated Pages convention this decision deliberately deviates from for the combined site specifically.
  • specs/guidelines/tool_chain.md.tmpl, specs/guidelines/general_coding.md.tmpl — the guidelines this decision names an explicit, temporary exception to (CI-runtime install) and avoids violating (mermaid.min.js injection, fixed at the image's entrypoint instead of a CI-script copy-loop).
  • cmd/mono_repo_root_ci.gowriteMonoRepoRootGitlabCI's new pages job.
  • internal/templating/general/mono_repo_root_mkdocs.go, internal/templating/general/files/mono_repo_root/mkdocs.yml.tmpl — the new root mkdocs.yml.
  • /Users/holgerzahnleiter/projekte/eigene/build-images/image_definitions/entrypoint-mkdocs.sh — the independent entrypoint fix, released as mkdocs:1.1.0.