---
status: accepted
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)                              | `<vm-gitlab>` (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).
