diff --git a/CLAUDE.md b/CLAUDE.md index 723aa0e..8014d49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,15 +54,15 @@ The structural, security, observability, and quality choices are recorded as ADR - **Charts + dashboards:** `D3 + Observable Plot` wrapped in `libs/shared/charts/`, one Angular component per chart type (bar, donut, line, stacked-bar, …). A11y baked in by the lib (SVG `
`) and scope literals (`etablissement:`) without server-side translation. For medico-social structures the FINESS already plays this role; for the rest we adopt the cascade/acteurs_plus posture of a portal-internal `code` string.
- **No FK from `UserScope.value` to `Structure.code`.** ADR-0026 already commits to this — the value is an opaque string at the type level so historical scope rows survive structure decommissioning. Validation happens at the admin-UI write path, not at the database level.
-- **Cascade is the long-term source of truth**, not the portal. v1 ships a small inline seed (the structures the test tenant exercises) precisely because ADR-0028's cascade sync will overwrite it.
-- **Do not replicate cascade's full model in v1.** Cascade carries `Pole` (lateral org grouping), `Service` (sub-units inside a structure), arbitrary self-referencing `structureParent`, and four per-source enrichment tables. None of those are required by the v1 scope axis. Defer to ADR-0028 (which will need them for the sync) or beyond, and accept that the portal's v1 schema is a subset.
+- **Cascade is the long-term source of truth**, not the portal. v1 ships a small inline seed (the structures the test tenant exercises) precisely because ADR-0029's cascade sync will overwrite it.
+- **Do not replicate cascade's full model in v1.** Cascade carries `Pole` (lateral org grouping), `Service` (sub-units inside a structure), arbitrary self-referencing `structureParent`, and four per-source enrichment tables. None of those are required by the v1 scope axis. Defer to ADR-0029 (which will need them for the sync) or beyond, and accept that the portal's v1 schema is a subset.
## Considered Options
@@ -44,7 +44,7 @@ Chosen option: **B — `Structure` with internal `code` PK + `kind` discriminato
1. it covers 100 % of APF's structure categories without smuggling a discriminator into nullable FINESS;
2. it keeps the scope-check hot path local (no remote call to cascade);
-3. it leaves a clean extension surface for ADR-0028 — adding `Pole`, `Service`, or per-source enrichment tables is additive, not destructive;
+3. it leaves a clean extension surface for ADR-0029 — adding `Pole`, `Service`, or per-source enrichment tables is additive, not destructive;
4. it preserves the externally-meaningful round-trip property: for medico-social structures we set `Structure.code = FINESS` at seed time, so `etablissement:0330800013` stays a readable scope literal where it can.
### Schema
@@ -97,14 +97,14 @@ model Structure {
// Unique when present. Cascade's StructureSourceFiness is the
// long-term authoritative carrier; the portal keeps it inline
// on Structure as a denormalised attribute for the v1 scope-axis
- // checks. ADR-0028's sync owns the write path.
+ // checks. ADR-0029's sync owns the write path.
finess String? @unique
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure is
// not registered with SIRENE (most antennes, dispositifs).
// Unique when present.
siret String? @unique
// Pléiades payroll code (6 chars). NULL in v1 — populated by
- // ADR-0028's Pléiades sync once it ships.
+ // ADR-0029's Pléiades sync once it ships.
codePaie String? @unique
// Parent delegation. NULL for structures not attached to a
// delegation (siège, mouvement national, …).
@@ -136,26 +136,26 @@ The v1 seed is intentionally small — only what the ADR-0026 test-tenant flow n
- **Delegation** Gironde (`33`) — sole delegation for v1.
- **Structure** rows: a handful of medico-social établissements (Bordeaux + complexe-merignac with `kind='medico_social'`, FINESS = code), one `siege` (no delegation, no FINESS), one `entreprise_adaptee` placeholder if the test matrix needs it.
-The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline seed is **superseded**, not extended, by the sync — the migration that adds the sync is also responsible for the cleanup truncation if needed.
+The full APF inventory ships once ADR-0029's cascade sync is live. The v1 inline seed is **superseded**, not extended, by the sync — the migration that adds the sync is also responsible for the cleanup truncation if needed.
### What ADR-0027 ships vs adjacent ADRs
-| Concern | ADR-0026 (identity) | ADR-0027 (this) | ADR-0028 (sync + facets) |
+| Concern | ADR-0026 (identity) | ADR-0027 (this) | ADR-0029 (sync + facets) |
| ----------------------------------------------------------- | ------------------- | ---------------------- | ------------------------ |
| `Region`, `Delegation`, `Structure` schema | — | ✅ | — |
| Inline reference-data seed for the test tenant | — | ✅ | — |
| Full APF inventory (~ ten thousand structures) | — | — | Cascade-sync population |
-| `Pole`, `Service`, arbitrary nesting, per-source enrichment | — | deferred | as ADR-0028's sync needs |
+| `Pole`, `Service`, arbitrary nesting, per-source enrichment | — | deferred | as ADR-0029's sync needs |
| `Structure.codePaie` populated | — | nullable (column only) | ✅ |
### Consequences
- Good, because every APF structure category fits the schema — antennes and dispositifs are first-class, not absence-of-FINESS edge cases.
- Good, because the scope-axis check stays local (no remote call to cascade on the guard hot path).
-- Good, because cascade's eventual sync (ADR-0028) writes into the existing column set — no schema churn at sync time, just a write strategy.
+- Good, because cascade's eventual sync (ADR-0029) writes into the existing column set — no schema churn at sync time, just a write strategy.
- Good, because the medico-social FINESS round-trip (`/api/structures/0330800013`) and scope-literal readability (`etablissement:0330800013`) are preserved for the structures that have a FINESS.
- Bad, because `Structure.code` semantics differ across kinds — sometimes it's a FINESS, sometimes a portal-internal slug. Operators reading the table need to consult `kind` to interpret `code`. Mitigation: the admin-UI scope-seeding screen ([ADR-0026](0026-person-user-portal-data-model.md) PR 2) shows `(code, name, kind)` together, not just the code.
-- Bad, because the schema is a strict subset of cascade's — `Pole` (lateral org grouping) and `Service` (sub-units) are missing. If a v1 consumer asks "show me everyone in the Pôle Santé" the portal cannot answer. Mitigation: documented as deferred; ADR-0028 picks it up alongside the sync.
+- Bad, because the schema is a strict subset of cascade's — `Pole` (lateral org grouping) and `Service` (sub-units) are missing. If a v1 consumer asks "show me everyone in the Pôle Santé" the portal cannot answer. Mitigation: documented as deferred; ADR-0029 picks it up alongside the sync.
- Bad, because the scope kind name `etablissement` no longer matches its value semantics (a `Structure.code` of any kind, not just `medico_social`). The matcher contract is documented; rename deferred to a possible ADR-0025 amendment.
- Neutral, because `UserScope.value` has no FK to `Structure.code`. ADR-0026 already commits to this; stale codes are tolerated at runtime and surface as a failed resource match, not a crash.
@@ -180,14 +180,14 @@ The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline
- Good, because the schema is the smallest possible thing.
- Bad, because it excludes ≥ 30 % of APF's structure inventory (antennes, dispositifs, entreprises adaptées, …) by construction.
- Bad, because making FINESS nullable to fix this smuggles the discriminator into absence-of-value semantics, which is harder to reason about than an explicit `kind` column.
-- Bad, because cascade's seven-type discriminator model demonstrably exists upstream — the portal not reflecting it just means the sync (ADR-0028) has to compress / discard information.
+- Bad, because cascade's seven-type discriminator model demonstrably exists upstream — the portal not reflecting it just means the sync (ADR-0029) has to compress / discard information.
### Option C — Full cascade replication
- Good, because the schema is a perfect mirror — no information is lost at sync time, no consumer is ever blocked by an absent table.
- Bad, because the v1 scope-axis check does not need Pole, Service, or self-referencing nesting — the join cost is paid every request for unused capability.
- Bad, because the four per-source enrichment tables (`StructureSourceFiness`, `StructureSourceSirene`, …) reflect cascade's role as the multi-source aggregator; the portal is downstream of cascade, not parallel to it. Owning four sync feeds the portal does not consume is misallocation.
-- Neutral, because additive extension from Option B's schema toward Option C's is straightforward — ADR-0028 picks up what its consumers need.
+- Neutral, because additive extension from Option B's schema toward Option C's is straightforward — ADR-0029 picks up what its consumers need.
### Option D — Remote read against cascade
@@ -205,7 +205,7 @@ The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline
**Follow-up ADRs.**
-- **[ADR-0028](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Specifies the structure-catalogue sync (cascade → portal), the person-catalogue sync (Pléiades + Acteurs+ → portal), the facet shapes (Salarié, Élu, Adhérent, Bénévole, Bénéficiaire, PartenaireExterne), the reconciliation policy between sync-owned and admin-UI-owned fields, and the schema extensions (Pole, Service, per-source enrichment) the sync may need.
+- **[ADR-0029](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Specifies the structure-catalogue sync (cascade → portal), the person-catalogue sync (Pléiades + Acteurs+ → portal), the facet shapes (Salarié, Élu, Adhérent, Bénévole, Bénéficiaire, PartenaireExterne), the reconciliation policy between sync-owned and admin-UI-owned fields, and the schema extensions (Pole, Service, per-source enrichment) the sync may need.
- **Possible future ADR-0025 amendment — rename scope kind `etablissement` to `structure`.** Triggered only if the vocabulary mismatch causes confusion in code review or operator-facing UIs; carries a closed-set catalogue rename + drift-gate update + all decorator literals.
**Source-of-truth investigations.** The cascade and acteurs_plus audits that drove this ADR live alongside the project lead's working notes (gitignored). The audit findings summarised in §"Context" are the durable artefacts — the raw notes are not part of the repo.
diff --git a/docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md b/docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md
new file mode 100644
index 0000000..9ac02bb
--- /dev/null
+++ b/docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md
@@ -0,0 +1,158 @@
+---
+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).
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 17e7d30..bc98112 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -71,3 +71,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | accepted | `security`, `backend`, `data` | 2026-05-20 |
| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account — portal-side identity model | accepted | `data`, `backend`, `security` | 2026-05-24 |
| [0027](0027-portal-side-organisational-hierarchy.md) | Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET | accepted | `data`, `backend` | 2026-05-24 |
+| [0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) | Migrate CI/CD + git hosting from Gitea to GitLab self-hosted | proposed | `infrastructure`, `process` | 2026-05-26 |