Files
apf_portal/docs/decisions/0015-cicd-gitea-actions.md
T
Julien Gautier 0e58e32d29 chore: relocate ADRs from decisions/ to docs/decisions/ to consolidate documentation
Move the ADR folder under docs/ alongside the rest of the project
documentation. Convention (flat folder, globally-sequential 4-digit
numbering, tags-based categorization, MADR 4.0.0 format) is unchanged
- only the path moved.

- git mv decisions docs/decisions preserves history for all 18 ADRs +
  README + template (19 files renamed in this commit).
- ADR-0001 amended in-place with a dated note documenting the
  relocation. Status remains 'accepted' - the location detail
  changed, the decision did not.
- All cross-references updated:
  - CLAUDE.md (~17 ADR links + 3 mentions of decisions/ in the Project
    rules section)
  - docs/README.md (now references decisions/ as a sibling under docs/)
  - docs/setup/03-angular-nx-monorepo.md (paths shortened from
    ../../decisions/ to ../decisions/, since setup/ and decisions/ are
    now both inside docs/)
  - docs/decisions/0003 ../CLAUDE.md adjusted to ../../CLAUDE.md
    (one extra level of nesting)
  - docs/decisions/template.md mention of the README path
  - notes/asvs-level-decision-briefing-rssi.md mention of the index

Sanity verified: every ADR link in CLAUDE.md, docs/setup/03, and
docs/decisions/0001 resolves to an existing file. pnpm nx run-many
-t lint passes on 8 projects.
2026-04-30 18:57:59 +02:00

20 KiB
Raw Blame History

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-04-30 R&D Lead
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 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:

// 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):

# .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