diff --git a/CLAUDE.md b/CLAUDE.md index 51257f4..f016070 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ The structural choices are recorded as ADRs and summarized below. Any change to - **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](decisions/0012-observability-pino-opentelemetry.md). - **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md). - **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md). +- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus future `a11y` and `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](decisions/0015-cicd-gitea-actions.md). - **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md). - **Runtime:** Node.js latest LTS major. diff --git a/decisions/0015-cicd-gitea-actions.md b/decisions/0015-cicd-gitea-actions.md new file mode 100644 index 0000000..738ad57 --- /dev/null +++ b/decisions/0015-cicd-gitea-actions.md @@ -0,0 +1,312 @@ +--- +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/adastra_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`, `on-prem`, plus capacity labels (`size:default`) for future job differentiation. Runner image baseline: a Debian image (aligned with the WSL development environment) pinned by SHA and rebuilt on a cadence by a security-scheduled job. + +**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 (6–18-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: 1–2 days. +- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.5–1 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: ~3–5 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). diff --git a/decisions/README.md b/decisions/README.md index 33c6cd7..4b242c4 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -58,3 +58,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu | [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 | | [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 | | [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 | +| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |