--- status: proposed date: 2026-05-26 decision-makers: R&D Lead tags: [infrastructure, process] --- # Migrate CI/CD + git hosting from Gitea to GitLab self-hosted ## Context and Problem Statement [ADR-0015](0015-cicd-gitea-actions.md) chose Gitea Actions as the v1 CI/CD platform, explicitly framed as _"level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months"_. That window opens now: the infra team has provisioned `vm-gitlab` at `10.100.201.10`, the team is about to scale beyond a single dev, and several Gitea-specific friction points have surfaced and are documented inline in `.gitea/workflows/ci.yml` (no Trivy / gitleaks official action — manual install; GitHub-API-bound action defaults that 404 against Gitea — `actions/checkout` token fallback; act_runner discovery quirks). The decision recorded here formalises the platform shift. This ADR is **decision-only** — the actual migration ships across four follow-up PRs in §"Migration sequence" below. ## Decision Drivers - **ADR-0015's explicit commitment.** The platform choice was always known to be temporary; we're inside the planned 6-18 month window. - **`vm-gitlab` is already provisioned.** No infra wait. The host the migration targets is up. - **Team scaling.** v1 is solo-developer; adding contributors needs better MR review affordances (inline suggestions, draft MRs, threaded discussions, reviewer assignment rules, MR templates). GitLab is materially better here than Gitea. - **Built-in security scanning.** GitLab CE ships native SAST + dependency scanning + secret detection. Consolidates today's manual `Trivy + gitleaks` plumbing in `.gitea/workflows/ci.yml` into one tool with documented blocking thresholds — less inline YAML to maintain, fewer "official action paywalled" workarounds. - **`act_runner` maturity ceiling.** It works, but it's a thin layer over `act` and has been the source of repeated setup-time friction (paywalled `gitleaks/gitleaks-action@v2`, github.com clone fallbacks in `aquasecurity/trivy-action`, missing standard runner image features). GitLab Runner with the Docker executor is the canonical CI execution model for the target platform. - **Operational support.** GitLab is the infra team's preferred git/CI platform — operating it long-term has organisational support, Gitea does not. - **Future Container Registry consumer.** When the BFF / SPA / docs site eventually ship Docker images (post-Phase-3), GitLab's built-in Container Registry is a natural target — currently no place to publish. ## Considered Options - **Option A — Status quo (Gitea + `act_runner`).** Discarded: ADR-0015's commitment is explicit, friction is accumulating, and `vm-gitlab` is already provisioned. Inaction would be the deviation, not the default. - **Option B — GitLab CE self-hosted on `vm-gitlab` (chosen).** On-prem (HDS / GDPR / likely ASVS L3 posture preserved), team's standard, immediate availability. - **Option C — Forgejo self-hosted.** Forgejo is Gitea's actively-maintained fork with better open-source governance. UX-identical to today; would keep Gitea Actions on `forgejo-runner`. Considered but discarded: same feature gaps as Gitea (MR review affordances, native scanning), and the infra team's choice is GitLab — picking Forgejo would deviate without closing the friction. - **Option D — Cloud SaaS (GitLab SaaS / GitHub Enterprise Cloud).** APF processes health + financial data; on-prem is materially preferable for the compliance posture. Cloud not pursued. ## Decision Outcome Chosen option: **B — GitLab CE self-hosted on `vm-gitlab`**, because it is the only option that combines: 1. **on-prem hosting** (compliance); 2. **enterprise-grade MR review affordances** (team scaling); 3. **native security scanning** that consolidates the current `Trivy + gitleaks` setup; 4. **a docker-native CI executor** (GitLab Runner with the Docker executor) replacing `act_runner`; 5. **immediate availability** — no infra wait. ### What carries over from ADR-0015 (unchanged) The **architectural principles** of ADR-0015 are preserved verbatim. Only the implementation host changes: - Trunk-based development with squash-merge. - Branch protection on `main`, all CI gates blocking. - **Thin pipeline YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:catalogue-drift`, `ci:audit`, `ci:commits`, `ci:perf`, `ci:gzip-budgets`) and Nx targets, runnable locally.** This is the load-bearing call from ADR-0015 — it was made specifically so the CI platform could swap with low cost. ADR-0028 cashes in that bet. - Self-hosted runners on the on-prem network. - Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. - Signed commits recommended (revisited at this migration — see §"Signed commits" below). - Conventional Commits validated locally (hook) AND in CI (defense in depth). ### What changes | Aspect | Before (ADR-0015) | After (ADR-0028) | | -------------------- | ------------------------------------------------------- | -------------------------------------------------------------------- | | Git host | `git.unespace.com` (Gitea) | `` (GitLab CE) | | Pipeline file | `.gitea/workflows/ci.yml` | `.gitlab-ci.yml` | | Runner | `act_runner` (Docker Compose on dev host) | GitLab Runner with Docker executor on `vm-gitlab` | | Dependency vuln scan | Trivy (manual install + `trivy-action` workaround) | GitLab Dependency Scanning (built-in) | | Secret scan | gitleaks (manual install + paywalled-action workaround) | GitLab Secret Detection (built-in) | | Bot account | `apf-portal-bot` (Renovate, Gitea) | `apf-portal-bot` (Renovate, GitLab) — same name, new auth token | | MR review | Gitea PRs | GitLab MRs with templates, draft, threaded, suggestions | | Conventional Commits | `pnpm ci:commits` against `origin/main` | unchanged — same script, different default-branch ref name if needed | ### Migration sequence This ADR ships **decision-only**. The migration is implemented across four PRs after acceptance: | Phase | PR | Effect | | ----- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | This ADR | Decision recorded; ADR-0015's "Gitea Actions" implementation choice flagged as superseded by §"Decision Outcome" here. | | 1 | `mirror-and-bootstrap` | `git push --mirror gitlab` from each repo (`apf_portal`, `apf-ai-service` proto vendoring, later `cascade`/`acteurs_plus` when needed). GitLab side: groups, projects, branch protection (mirror Gitea's), MR templates, deploy keys, Renovate reconfigured for GitLab. **No `.gitlab-ci.yml` yet** — Gitea pipelines continue to gate. | | 2 | `gitlab-ci-pipeline` | `.gitlab-ci.yml` lands alongside `.gitea/workflows/ci.yml` — **both run in parallel for ~1 calendar week** to validate parity. Replace the manual `Trivy + gitleaks` install in the scan job with GitLab's `SAST.gitlab-ci.yml` + `Secret-Detection.gitlab-ci.yml` includes. GitLab Runner started + registered on `vm-gitlab`. | | 3 | `cutover` | Remotes flip in CLAUDE.md, READMEs, `docs/setup/01-dev-debian-vm-setup.md` §8.3 hand-off block. `.gitea/workflows/` deleted, `infra/ci-runners.compose.yml` deleted, the three `infra/data/runner-*/` directories deleted (or moved out of the repo). Gitea moves to read-only / archive (not decommissioned — old PR URLs in commit messages stay resolvable as historical artefacts). | | 4 | `cleanup` | Stale Gitea references swept across docs (`docs/decisions/0015-…` annotated, this ADR amended if anything drifted during 1-3). `RENOVATE_PLATFORM`, `GITHUB_TOKEN` secret naming, and the workflow's `apf-portal-bot` exclusion rule reviewed. | Phases 1-4 run on the user's calendar; this ADR doesn't pin dates. ### Signed commits ADR-0015 §"Signed commits recommended, revisited at GitLab migration" — that revisit happens here. The recommendation: - Once GitLab is the active host, **enable required signed commits** on `main` (GitLab's `commit signing` policy on the project's protected branch). GitLab supports both GPG and SSH signing keys. - Pair with GnuPG agent forwarding (covered in `docs/setup/01-dev-debian-vm-setup.md` §8.5) so dev VM operations work without holding a private signing key on the VM. - The `apf-portal-bot` (Renovate) service account needs a dedicated signing key — to be issued at PR 1 (mirror-and-bootstrap). ### Consequences - **Good**, because ADR-0015's anticipated migration arrives within the committed window — the ADR record stays honest, no zombie commitments. - **Good**, because MR review affordances improve materially (inline suggestions / drafts / threaded discussions / required-reviewer rules) — relevant from the moment a second contributor joins. - **Good**, because GitLab's built-in scanning consolidates the dual `Trivy + gitleaks` setup into one configured tool with opinionated defaults. ADR-0015's "two paywalled-action workarounds" section of `.gitea/workflows/ci.yml` (~30 lines of inline rationale + `curl + tar` boilerplate) goes away. - **Good**, because GitLab Runner is mature and well-documented relative to `act_runner`; removes a class of friction we've hit during runner ops. - **Good**, because Container Registry is built-in — when the project eventually ships Docker images, a natural target exists without a new infra ask. - **Bad**, because every dev must update remote URLs on every clone (`git remote set-url origin git@…`). Single-line operation but a coordination point at cutover. - **Bad**, because existing references to Gitea PRs in commit messages and ADRs (`#213`, `#217`, `#219`, …) become 404s if Gitea is decommissioned. Mitigation: keep Gitea in **read-only / archive** mode rather than decommissioning — long-term cost is low (one VM at idle). - **Bad**, because Renovate needs reconfiguration. Mitigation: Renovate has first-class GitLab support; the config translation is documented and lands in PR 1. - **Bad**, because GitLab CE itself is a heavier piece of software to operate (Postgres + Redis + Sidekiq + nginx + …) than Gitea. Mitigation: infra team owns the platform's operability — same posture as `vm-dev`, just a different scope. - **Neutral**, because the gates, scripts, and Nx orchestration are **unchanged**. Thin-YAML-over-portable-scripts was made specifically so the CI platform could swap with low cost. The bet pays off here. - **Neutral**, because licensing: GitLab CE (the free / open-source edition) covers everything we need for v1 (CI/CD, MRs, Container Registry, basic SAST / Dependency / Secret scanning). The "Ultimate" tier features (advanced SAST, security dashboard, compliance pipelines) are paid — revisited if and when the compliance bar lands them in scope. ### Confirmation - **Parity validation.** Phase 2 runs both pipelines in parallel on every PR for ~1 calendar week. Sign-off requires: every Gitea-Actions gate has its GitLab-CI counterpart, every commit that passes Gitea also passes GitLab, no false negatives observed. - **Performance check.** GitLab CI's pipeline view shows total runtime. Acceptance bar: GitLab pipeline ≤ 1.5× current Gitea pipeline (the act_runner setup currently shares the dev host's warm Docker cache; GitLab Runner has a small per-job container-start tax — expected, not blocking). - **Renovate sanity check.** Renovate creates at least one MR on GitLab successfully before phase 3 (cutover) — chosen MR being a small dependency bump on a non-critical lib so it's safe to merge or close on either platform. - **Hooks parity.** The current pre-commit suite (`husky` + `lint-staged` + `commitlint`) is platform-agnostic — verified at PR 2 by running `pnpm prepare && git commit --allow-empty -m 'test'` on a fresh clone from GitLab. ## Pros and Cons of the Options ### Option B — GitLab CE self-hosted on `vm-gitlab` (chosen) - Good, because: see "Decision Outcome" above. - Good, because GitLab is the infra team's preferred platform — operational support exists long-term. - Good, because Container Registry is built-in (potential consumer when shipping Docker images). - Good, because GitLab Pages is built-in — could replace the standalone VitePress hosting in [ADR-0022](0022-docs-site-vitepress.md) if we ever want to consolidate (decision-only — not in scope for this ADR). - Bad, because GitLab CE is heavier to operate than Gitea. Mitigation: infra team ownership. - Neutral, because licensing (CE covers v1 needs). ### Option A — Status quo (Gitea) - Good, because: zero migration cost today. - Bad, because ADR-0015 commitment to migrate. - Bad, because: friction points (act_runner setup tax, MR review primitives, scan tool plumbing) compound as the team grows. - Bad, because: `vm-gitlab` is already provisioned — staying on Gitea wastes the infra investment and leaves a parallel host to operate. ### Option C — Forgejo self-hosted - Good, because: governance / fork direction is more aligned with open-source norms than Gitea. - Good, because: migration from Gitea is trivial (Forgejo is a drop-in). - Bad, because: same feature gaps as Gitea (MR review primitives, native scanning) — Forgejo is largely a same-feature-set fork with better governance, not a more capable product. - Bad, because: infra team's choice is GitLab — Forgejo would be a per-project deviation. ### Option D — Cloud SaaS - Good, because: zero operational burden for the platform itself. - Bad, because: APF's data classification (health + financial) plus likely ASVS L3 makes on-prem materially preferable. - Bad, because: corp egress policies likely require ingress / egress rules to cloud SaaS we don't have today. ## More Information **Relationship to ADR-0015.** ADR-0028 supersedes the specific implementation choice _"Gitea Actions"_ in [ADR-0015 §"Decision Outcome"](0015-cicd-gitea-actions.md). The architectural principles in the rest of ADR-0015 (trunk-based + squash-merge, branch protection, all gates blocking, thin YAML over portable scripts, on-prem runners, signed commits, Conventional Commits, defense-in-depth) **carry over unchanged**. ADR-0015's frontmatter status stays `accepted`; a note at its top points at ADR-0028 for the platform shift. **Renumbering.** This ADR takes number `0028`. The previously-reserved placeholder for _"Pléiades + Acteurs+ syncs + facet schemas"_ — referenced in [ADR-0026](0026-person-user-portal-data-model.md) and [ADR-0027](0027-portal-side-organisational-hierarchy.md) as `ADR-0028` — shifts to **ADR-0029**. The `(#)` placeholder links in those two ADRs are updated in this PR so the chain stays consistent. **Phasing recap.** Decision-only. Implementation across four PRs: 1. `chore(gitlab): mirror repos + bootstrap groups + branch protection + Renovate (no CI yet)` 2. `ci(gitlab): land .gitlab-ci.yml alongside .gitea/workflows/ — parallel run` 3. `chore(gitlab): cutover — flip remotes in docs, drop .gitea/workflows + infra/ci-runners` 4. `chore(gitlab): cleanup — sweep stale references + finalise signed-commit policy` **Follow-up ADRs.** - **[ADR-0029](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Previously numbered 0028 in [ADR-0026](0026-person-user-portal-data-model.md) / [ADR-0027](0027-portal-side-organisational-hierarchy.md) / `CLAUDE.md`. Renumbered in this PR's diff. Content unchanged from the original placeholder description. - **A future ADR — required signed commits.** Optional, only if §"Signed commits" above turns out to need its own decision record (e.g. if APF's RSSI lands a specific policy on signing keys, key escrow, or revocation).