--- status: accepted date: 2026-04-30 decision-makers: R&D Lead tags: [infrastructure, process] --- # CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts ## Context and Problem Statement The repository currently lives on Gitea (`gitea@git.unespace.com:julien/apf_portal.git`). The organisation plans to migrate to GitLab on a 6–18-month horizon. The pipeline shape we adopt now must be (a) operational on Gitea immediately, (b) low-cost to migrate later, and (c) consistent with the project's anti-bricolage and security-first values. We also need to fix the branch model, the merge strategy, the required gates, the protection rules on `main`, and the location of the orchestration logic — all of these have first-order effects on the Nx scaffold (`package.json` scripts, lint configs, branch tags, conventional-commits validation surface). This ADR is split into two levels of decision: - **Level 1 — vendor-neutral.** Survives the future GitLab migration unchanged. - **Level 2 — Gitea Actions implementation.** Will be rewritten when GitLab is adopted; a future ADR will supersede the level-2 sections without affecting level-1. ## Decision Drivers - Pipeline portability across CI vendors (Gitea now, GitLab later, possibly other platforms in the future). - All gates blocking — no "warnings ignored". Either we fail or we adjust the threshold via ADR. - Trunk-based development for fast feedback and continuous integration discipline. - Compatibility with the Nx monorepo's `affected` model and remote cache. - Self-hosted runners (on-prem context — see ADR-0008's hosting constraint). - Mature, mainstream tooling — anti-bricolage applies particularly here. - Local enforcement (Husky + lint-staged + commitlint, ADR-0007) plus CI defense in depth — the same checks run twice, on purpose. ## Considered Options ### Branch / merge strategy (level 1) - **Trunk-based + squash-merge.** (Chosen.) - Trunk-based + rebase-merge. - Trunk-based + merge commit. - GitFlow. ### Required reviewer count on PRs to `main` (level 1) - **0 in v1, ≥1 once a second active contributor exists.** (Chosen.) - 1 always (blocks if solo). - 2 (heavy for a small team). ### Signed commits (level 1) - **Recommended but not required in v1; reconsidered at the GitLab migration ADR.** (Chosen.) - Required in v1. - Never. ### Conventional Commits validation (level 1) - **Local `commit-msg` hook (ADR-0007) + CI defense-in-depth on the PR commit range.** (Chosen.) - Local hook only. - CI only. ### Pipeline orchestration logic location (level 1) - **Thin YAML — logic lives in `package.json` scripts and Nx targets, the workflow file orchestrates.** (Chosen.) - Logic in YAML, scripts called step by step. ### CI engine (level 2 — Gitea-specific) - **Gitea Actions** (built-in since Gitea 1.19, GitHub Actions-compatible YAML). (Chosen.) - Drone CI alongside Gitea. - Concourse / Tekton / Buildkite / etc. ### Runner topology (level 2) - **≥ 3 self-hosted `act_runner` instances on-prem.** (Chosen.) - Single runner. - Cloud-hosted (rejected — on-prem constraint). ## Decision Outcome ### Level 1 — vendor-neutral decisions **Branch model.** Trunk-based with `main` always deployable. Feature branches are short-lived (hours to days), named `feat/` or `fix/` or `chore/`. Releases happen by tagging `vX.Y.Z` on `main`. **Merge strategy.** Squash-merge only. The squash subject is the PR title and must be a valid Conventional Commits message; the squash body inherits the PR body. This produces a clean linear history on `main` where each commit corresponds 1:1 to a PR. Rebase-merge and merge-commit are disabled at the platform level. **Branch protection on `main`:** - direct push: forbidden (no exceptions, including the project lead); - force push: forbidden; - linear history: required (consistent with squash-merge); - required status checks: every CI gate listed below, all blocking; - required PR review approvals: 0 in v1 (solo), revisited to ≥1 once a second active contributor joins (a follow-up ADR or amendment will mark the date); - branch deletion after merge: required; - merge of stale branches: PRs must be up-to-date with `main` before merging (or use a merge queue once GitLab provides one). **Required CI gates** (every gate is blocking; failing any blocks the merge): | Gate | What it runs | Tooling | | ------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `format` | Prettier check, no auto-fix | `prettier --check` via `pnpm nx format:check` | | `lint` | ESLint across affected projects, including `@nx/enforce-module-boundaries` | `pnpm nx affected -t lint` | | `type-check` | TypeScript strict, no emit | `pnpm nx affected -t type-check` | | `test` | Unit/component tests | Vitest (front, ADR-0004) and Jest (back, ADR-0005), via `pnpm nx affected -t test` | | `build` | Production builds of affected apps and libs | `pnpm nx affected -t build` | | `audit` | Dependency vulnerabilities | `pnpm audit` + Trivy filesystem scan | | `secret-scan` | Repo-wide secret detection | `gitleaks` | | `commit-lint` | Conventional Commits validation on the PR commit range | `commitlint --from --to HEAD` | | `a11y` | Pending — defined by future a11y ADR (axe-core in e2e) | (placeholder) | | `perf` | Pending — defined by future perf ADR (Lighthouse CI) | (placeholder) | **Conventional Commits.** Already enforced locally via the `commit-msg` hook (ADR-0007). In CI, `commitlint --from origin/main --to HEAD` runs against the PR commit range as defense in depth — even if a contributor bypasses the local hook, the CI gate catches it. **Signed commits.** Recommended but not required in v1. Setup overhead for contributors (GPG or SSH signing key) is non-trivial relative to the marginal value with a single contributor and a well-controlled host. The decision is revisited as part of the GitLab migration ADR (GitLab has stronger built-in tooling for centralised signing policies than current Gitea). **Logic location — the "thin YAML" pattern.** All non-trivial CI logic lives in `package.json` scripts and Nx targets, _not_ in the workflow YAML. The YAML's role is restricted to: checkout, runtime setup, cache restoration, and calling a single high-level script per job. Concretely: ```jsonc // package.json (excerpt — lands with the scaffold) "scripts": { "ci:check": "pnpm exec nx affected -t format:check lint type-check test build", "ci:scan": "pnpm audit --audit-level=moderate && pnpm exec trivy fs --skip-dirs node_modules --exit-code 1 . && pnpm exec gitleaks detect --no-banner --redact", "ci:commits": "pnpm exec commitlint --from $COMMIT_LINT_FROM --to HEAD --verbose" } ``` The migration to GitLab then becomes a rewrite of the YAML wrappers (a few dozen lines) and not a re-derivation of the gates — these scripts are platform-agnostic, runnable locally, and serve as the source of truth. **Caching.** Two cache surfaces, both portable: - the `pnpm` store, keyed on `pnpm-lock.yaml`; - the Nx local cache (`.nx/cache`), keyed on the project graph. The level-2 implementation wires both via the CI vendor's cache action; the level-1 contract is "these two paths must be cached". **Secrets policy.** - Secrets live exclusively in CI vendor variables (Gitea → GitLab later). Naming convention: `SCOPE_PURPOSE` (e.g. `BFF_DATABASE_URL`, `OBO_CACHE_ENCRYPTION_KEY`). - No secret ever in source. `gitleaks` enforces. - Rotation procedures, key vault, and operator runbooks belong in the future operations / secret-management ADR. **Container images / deployment.** Explicitly out of scope of this ADR. CI v1 builds and tests; image build and deploy will land with the on-prem infrastructure ADR (phase 3). ### Level 2 — Gitea-specific implementation **Engine.** Gitea Actions, available since Gitea 1.19. The workflow YAML is GitHub Actions-syntax-compatible, which means most third-party actions (`actions/checkout`, `actions/setup-node`, `pnpm/action-setup`, etc.) work unchanged. This compatibility is also a partial migration hedge: the same workflows can be ported to GitHub Actions with near-zero changes if the org pivots to GitHub instead of GitLab. **Runners.** Three self-hosted `act_runner` instances on internal infrastructure. The first runner is deployed to validate the pipeline; the second and third are added before the project hits any non-trivial PR volume. Runners are labelled `self-hosted` and `on-prem`, plus capacity labels (`size:default`) for future job differentiation. **Job container image** is `catthehacker/ubuntu:act-22.04` — Ubuntu LTS, the de facto standard image for `act`-compatible runners; bundles Node, Python, git, common build tools, and the Docker CLI, so JavaScript actions (`actions/checkout`, `actions/setup-node`, etc.) work without per-job container declarations. Labels are registered in the `