Files
apf_portal/docs/decisions/0015-cicd-gitea-actions.md
T
Julien Gautier a706e3ddc4
CI / scan (pull_request) Successful in 3m2s
CI / commits (pull_request) Successful in 3m12s
CI / check (pull_request) Successful in 3m23s
CI / a11y (pull_request) Successful in 3m10s
Docs site / build (pull_request) Successful in 3m44s
CI / perf (pull_request) Successful in 6m27s
docs(adr-0028): accept CI/CD + git hosting migration to GitLab
ADR-0028 shipped as proposed in #226. No open questions on drivers,
considered options, or the 4-phase migration sequence - promote to
accepted to unblock Phase 1 (mirror-and-bootstrap).

ADR-0015 gets a status-update blockquote at the top calling out that
the platform choice "Gitea Actions" is superseded by ADR-0028 while
the architectural principles in the rest of ADR-0015 (trunk-based +
squash, all-gates-blocking, thin YAML, on-prem runners, signed
commits, Conventional Commits) carry over unchanged. ADR-0015's
frontmatter status stays accepted - this is the canonical MADR way
to handle partial supersession without rewriting the decision record.

CLAUDE.md CI/CD architecture bullet rewritten to reflect the
accepted-but-not-yet-implemented state. Roll-up bumped to 0001 -> 0028
accepted. Side-fix: the bullet referenced a non-existent `ci:scan`
script; replaced with the real names (ci:catalogue-drift, ci:audit,
ci:perf, ci:gzip-budgets).

No code changes, no ci:check impact.
2026-05-26 10:49:39 +02:00

328 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
> **Status update — 2026-05-26.** The platform choice **"Gitea Actions"** in §"Decision Outcome" below is superseded by [ADR-0028](0028-migrate-cicd-and-git-hosting-to-gitlab.md) — migration to GitLab CE on `vm-gitlab` accepted, 4-phase rollout in progress. The architectural principles in the rest of this ADR (trunk-based + squash-merge, branch protection, all CI gates blocking, **thin YAML over portable scripts**, on-prem runners, Conventional Commits double-check, signed commits) **carry over unchanged**. This ADR's frontmatter status stays `accepted`; only the platform implementation shifts.
## 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 618-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/<short-slug>` or `fix/<slug>` or `chore/<slug>`. 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 <base> --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 `<label>:docker://<image>` format; without the `docker://` suffix, `act_runner` falls back to a minimal default image and JavaScript actions fail with `Cannot find: node in PATH`. The image is pinned by tag; the `security-scheduled.yml` workflow re-pulls it weekly to pick up patches.
**Workflow file structure.**
- `.gitea/workflows/ci.yml` — runs on `pull_request` and `push` to `main`. Hosts the `check`, `scan`, and `commits` jobs.
- `.gitea/workflows/release.yml` — runs on `push` of a `vX.Y.Z` tag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands.
- `.gitea/workflows/security-scheduled.yml` — runs weekly via `schedule:` cron. Re-runs Trivy and gitleaks on the full tree (not just affected), and triggers Renovate (configuration covered by the security baseline ADR).
Three files; small surface; clear scope per file.
**Workflow shape (illustrative — final lands with the scaffold):**
```yaml
# .gitea/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: nrwl/nx-set-shas@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm ci:check
scan:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm ci:scan
commits:
if: github.event_name == 'pull_request'
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: COMMIT_LINT_FROM=origin/main pnpm ci:commits
```
This is a sketch, not the final file — the final lands with the scaffold and any iteration on it is not by itself an ADR-worthy change.
**Branch protection (Gitea-side configuration):** `Settings → Branches → Add rule` on `main`:
- Disable force push: yes;
- Disable direct push: yes (only PR merge);
- Required status checks: `check`, `scan`, `commits`, plus future `a11y`, `perf`;
- Require pull request reviews: 0 in v1, raise to 1 when a second contributor joins;
- Require linear history: yes;
- Delete head branches after merge: yes.
### Migration to GitLab — what changes, what doesn't
When the GitLab migration happens (618-month horizon), a new ADR will be written that **supersedes only the level-2 sections** of this ADR. The level-1 decisions stand unchanged. Concretely the migration touches:
- `.gitea/workflows/*.yml``.gitlab-ci.yml` (rewrite — the _gates_ are the same, the _DSL_ is different). Estimate: 12 days.
- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.51 day.
- Gitea branch protection rules → GitLab merge request approval rules. Same concepts, different UI. Estimate: a few hours.
- Secrets re-creation in GitLab CI/CD variables. Same naming convention, copy values. Estimate: a few hours.
The `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`), the Nx workspace, the dependency manifest, the entire code surface, and the level-1 decisions are unchanged.
### Consequences
- Good, because the gates are written once and run twice (locally via hooks, in CI as defense). Drift is impossible without breaking both layers.
- Good, because thin YAML keeps CI reproducible locally — anyone can run `pnpm ci:check` to mirror what the runner does.
- Good, because Gitea Actions' GHA-compatible syntax doubles as a hedge: the same YAML can land on GitHub Actions if the org's plans change again.
- Good, because squash-merge produces a Conventional-Commits-only history on `main` — clean changelog generation, predictable release notes, smooth semver inference.
- Good, because branch protection is enforced at the platform level, not at convention level.
- Good, because the migration weight is bounded and transparent: ~35 days of dev/ops work, no code rewrite.
- Bad, because the level-2 sections will need to be rewritten at the GitLab migration. This is the explicit trade-off; it is accepted because the alternatives (going GitLab now without the existing tooling, or staying CI-less until GitLab) cost more.
- Bad, because trunk-based with required-reviewers=0 in v1 leaves the project lead as the sole gatekeeper. Mitigated by mandating green CI (which the lead cannot bypass without rewriting branch protection — a deliberate, audit-visible action).
- Bad, because `act_runner` is younger than GitLab Runner; expect occasional rough edges, especially around large action ecosystems. Mitigated by pinning third-party actions by SHA and cadence-rebuilding the runner image.
- Bad, because deferring signed commits to v2 means the v1 history won't carry attribution-grade signatures. Reasonable for a small team; revisit at GitLab migration.
### Confirmation
- `.gitea/workflows/ci.yml`, `release.yml`, `security-scheduled.yml` exist with the structure above. The `release.yml` may be a stub until the deploy ADR lands.
- `package.json` exposes `ci:check`, `ci:scan`, `ci:commits` scripts. Each is runnable locally and produces the same exit code as the CI job.
- Gitea branch protection on `main` has the rules above; configuration is documented in `docs/operations/branch-protection.md` (created with the scaffold).
- `Trivy`, `gitleaks`, and `commitlint` are in `devDependencies` (or available as actions pinned by SHA — both acceptable).
- At least three `act_runner` instances are registered to the org; their bootstrap and update procedure live in an operations doc.
- CI exits non-zero on any gate failure; no gate is `continue-on-error: true`.
- Runner images are pinned by SHA in the workflows. The `security-scheduled.yml` job rebuilds the runner image weekly and reports to the security audit feed.
- A future migration ADR (GitLab) explicitly references this ADR, supersedes only level 2, and inherits level 1.
## Pros and Cons of the Options
### Branch / merge strategy
#### Trunk-based + squash-merge (chosen)
- Good, because clean linear history on `main`, one squash commit per PR.
- Good, because Conventional Commits + squash-merge yields a directly machine-readable changelog.
- Good, because feature branches stay short-lived — pressure against long-lived branches becomes structural, not cultural.
- Bad, because contributors lose granular commit history on the merged branch (the squash collapses it). Mitigated: the PR retains the full history for review purposes.
#### Trunk-based + rebase-merge
- Good, because preserves individual commits without merge bubbles.
- Bad, because contributors must groom every commit to be CI-clean (each commit must compile and pass tests if we want a clean bisect history). High discipline cost; squash-merge gets most of the benefit at lower cost.
#### Trunk-based + merge commit
- Good, because preserves the full history, including the branch topology.
- Bad, because produces messy merge bubbles on `main`; conflicts with the "linear history" branch protection.
#### GitFlow
- Good, because release branches isolate stabilisation.
- Bad, because heavy for a continuously deployable monorepo; redundant with semver tags on a trunk-based main; introduces the "long-lived `develop` branch" anti-pattern.
### CI engine (level 2)
#### Gitea Actions (chosen)
- Good, because built-in to Gitea, no extra deployment.
- Good, because GitHub Actions syntax means transferable skills and a partial portability hedge.
- Good, because actively developed by the Gitea team.
- Bad, because younger ecosystem than GitLab CI or GitHub Actions proper — expect occasional rough edges.
#### Drone CI
- Good, because mature, lean, opinionated.
- Bad, because separate deployment and operational surface; YAML is Drone-specific (less portable than GHA-compatible Gitea Actions).
#### Concourse / Tekton / Buildkite
- Good, because powerful for complex pipelines.
- Bad, because over-engineered for the v1 scope, and orthogonal to the Gitea/GitLab decision axis.
### Required reviewer count
#### 0 in v1, raise later (chosen)
- Good, because doesn't block solo development.
- Good, because the green-CI requirement still prevents the project lead from merging broken code without effort.
- Bad, because relies on the project lead's discipline (and CI's correctness) for code quality.
#### 1 always
- Good, because rigour.
- Bad, because blocks if there's only one contributor — would force the project lead to merge their own PRs by overriding protection, which is the opposite of the intended behaviour.
### Signed commits
#### Optional in v1, revisited at GitLab migration (chosen)
- Good, because no setup overhead for contributors during a phase where the priority is shipping the structural ADRs and scaffolding.
- Bad, because v1 history carries no attribution-grade signatures. Acceptable: the host (Gitea) records the user identity on each commit.
#### Required in v1
- Good, because rigorous attribution from day one.
- Bad, because every contributor must set up GPG or SSH signing — high friction for early stages.
## More Information
- Gitea Actions: https://docs.gitea.com/usage/actions/overview
- `act_runner`: https://gitea.com/gitea/act_runner
- GitHub Actions reference (compatible with Gitea Actions): https://docs.github.com/actions
- Conventional Commits: https://www.conventionalcommits.org/
- commitlint: https://commitlint.js.org/
- Trivy: https://github.com/aquasecurity/trivy
- gitleaks: https://github.com/gitleaks/gitleaks
- `nrwl/nx-set-shas`: https://github.com/nrwl/nx-set-shas
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx workspace + `affected`), [ADR-0007](0007-pre-commit-hooks-and-conventional-commits.md) (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (`a11y` gate), performance budgets (`perf` gate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).