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.
21 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | ||
|---|---|---|---|---|---|
| accepted | 2026-04-30 | R&D Lead |
|
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 — migration to GitLab CE on
vm-gitlabaccepted, 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 staysaccepted; 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 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
affectedmodel 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-msghook (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.jsonscripts 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_runnerinstances 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
mainbefore 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:
// 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
pnpmstore, keyed onpnpm-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.
gitleaksenforces. - 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 onpull_requestandpushtomain. Hosts thecheck,scan, andcommitsjobs..gitea/workflows/release.yml— runs onpushof avX.Y.Ztag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands..gitea/workflows/security-scheduled.yml— runs weekly viaschedule: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):
# .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 futurea11y,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:checkto 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_runneris 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.ymlexist with the structure above. Therelease.ymlmay be a stub until the deploy ADR lands.package.jsonexposesci:check,ci:scan,ci:commitsscripts. Each is runnable locally and produces the same exit code as the CI job.- Gitea branch protection on
mainhas the rules above; configuration is documented indocs/operations/branch-protection.md(created with the scaffold). Trivy,gitleaks, andcommitlintare indevDependencies(or available as actions pinned by SHA — both acceptable).- At least three
act_runnerinstances 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.ymljob 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
developbranch" 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 (Nx workspace +
affected), ADR-0007 (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (a11ygate), performance budgets (perfgate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).