GitLab & CI/CD
This document describes how this project (https://gitlab.techeve.de/techeve/form-gateway) is set up on the GitLab server, and how to reproduce the same setup for a new project (reference pattern: techeve/uploader, techeve/lcm). A specific characteristic of this project: the Go jobs need access to the private ORM++ module — CI handles this by setting GOPRIVATE and an insteadOf rewrite using the CI_JOB_TOKEN (see .gitlab-ci.yml).
Branching model
Section titled “Branching model”main ── protected: no direct push, only merge requests from develop, the pipeline must be green, another developer must approve. Every merge with a new VERSION automatically creates a tag + release and rolls the .deb packages out to repo.techeve.de.develop ── default branch: development happens here (directly or via feature branches with an MR into develop).feature/* ── optional feature branches, the MR target is always develop.The path of a change: feature/xyz → MR → develop → packaging/prepare-release.sh → MR → main → automatic release + apt rollout.
Step-by-step setup
Section titled “Step-by-step setup”All steps can be done via the web UI or — as documented here — via the GitLab REST API with a Personal Access Token (scope api):
export GITLAB=https://gitlab.techeve.de/api/v4export TOKEN=<personal-access-token>1. Create the project in the group
Section titled “1. Create the project in the group”# Determine the group ID:curl -s -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/groups?search=techeve"
# Create the project (namespace_id = group ID):curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects" \ --data-urlencode "name=form-gateway" \ --data-urlencode "namespace_id=3" \ --data-urlencode "visibility=internal" \ --data-urlencode "initialize_with_readme=false" \ --data-urlencode "auto_devops_enabled=false"2. Push the code, create branches
Section titled “2. Push the code, create branches”git init -b main && git add -A && git commit -m "Initial import"git remote add origin https://gitlab.techeve.de/techeve/form-gateway.gitgit push -u origin maingit switch -c develop && git push -u origin developSet develop as the default branch (new clones/MRs start there):
curl -s -X PUT -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>" \ --data-urlencode "default_branch=develop"3. Protect main: no direct push, only merge requests
Section titled “3. Protect main: no direct push, only merge requests”# Remove any default protection, then recreate it strictly:curl -s -X DELETE -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>/protected_branches/main"curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>/protected_branches" \ --data-urlencode "name=main" \ --data-urlencode "push_access_level=0" \ --data-urlencode "merge_access_level=30" \ --data-urlencode "allow_force_push=false"push_access_level=0— no one may push directly (not even Maintainers).merge_access_level=30— Developer and above may merge via merge request.
develop is also protected, but in a work-friendly way (Developers may push and merge, no force push):
curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>/protected_branches" \ --data-urlencode "name=develop" \ --data-urlencode "push_access_level=30" \ --data-urlencode "merge_access_level=30" \ --data-urlencode "allow_force_push=false"“MRs into main only from develop”: GitLab cannot natively restrict the source of an MR. This is instead enforced by the CI job check:mr-source (see .gitlab-ci.yml): it fails in MR pipelines targeting main if the source is not develop — and because main can only be merged with a green pipeline, the rule is binding.
4. Merge request rules
Section titled “4. Merge request rules”curl -s -X PUT -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>" \ --data-urlencode "only_allow_merge_if_pipeline_succeeds=true" \ --data-urlencode "allow_merge_on_skipped_pipeline=false" \ --data-urlencode "only_allow_merge_if_all_discussions_are_resolved=true" \ --data-urlencode "remove_source_branch_after_merge=false"- The pipeline must be green — this makes the tests (Go, E2E, audits) and the
check:mr-sourcejob mandatory for every merge into main. - Open discussions block the merge (review discipline).
Approval from another developer: the number of required approvals is set with:
curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>/approval_rules" \ --data-urlencode "name=Mindestens ein Reviewer" \ --data-urlencode "approvals_required=1"curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" "$GITLAB/projects/<id>/approvals" \ --data-urlencode "merge_requests_author_approval=false"Note on GitLab CE: Enforced approval rules (merge button locked until N approvals) are a Premium feature. On a CE server (as here,
enterprise: false), developers can approve MRs, but the approval is not hard-enforced — the four-eyes principle then applies as a binding team convention, technically backed bypush_access_level=0(nothing gets in without an MR) and the mandatory pipeline. After an upgrade to Premium, the commands above take hard effect immediately.
The CI/CD pipeline
Section titled “The CI/CD pipeline”Defined in .gitlab-ci.yml, it runs on the runner tagged docker (Docker executor, jobs run inside images):
| Stage | Job | Purpose |
|---|---|---|
| check | check:mr-source | MRs into main only from develop (fails otherwise) |
| check | check:commits | enforces Conventional Commits in MR pipelines |
| check | version | main only: reads the (on develop prepared) VERSION and checks whether a tag already exists for it |
| test | frontend | npm ci + npm audit (gate) + Vite build; dist/ as artifact |
| test | backend | go vet + go test ./... (in-memory SQLite) |
| test | golangci-lint | Go linters per .golangci.yml (errcheck, staticcheck, govet, ineffassign, unused) |
| test | govulncheck | Go vulnerability scan (gate) |
| test | e2e | Playwright against the real binary (on develop/main and in MRs) |
| build | binaries | cross-compile: Linux amd64/arm64, Windows amd64, macOS arm64/amd64 |
| build | packages:deb | .deb packages (amd64/arm64) with a systemd service via nfpm |
| release | release | main only: tag v<VERSION> + release with the prepared changelog (see below) |
| deploy | deploy:apt | rolls the .deb packages out to repo.techeve.de (see below) |
| deploy | docs-build/docs-deploy | builds docs/ as a Starlight page and uploads it to $DOCS_URL |
Versioning in CI: every job builds with the state of the VERSION file from the commit (on main, the version job passes the same value through as NEXT_VERSION); the build number is the unique pipeline number (CI_PIPELINE_IID) — the local .buildnumber is not modified in CI.
Provenance of a release
Section titled “Provenance of a release”The version job checks before every release that the commit to be tagged is actually an ancestor of origin/main (git merge-base --is-ancestor), and aborts otherwise. The rules already bind release and deploy to main; this check additionally holds if the pipeline is restructured later (tag pipeline, manual run, changed rules), and it documents the provenance in the job log.
Commit convention (Conventional Commits)
Section titled “Commit convention (Conventional Commits)”The version number and changelog are generated automatically from the commit messages — which is why the CI job check:commits enforces the following format in every MR pipeline:
typ(scope): beschreibung # scope optional| Commit type | Effect on the version | Changelog category |
|---|---|---|
feat!: / fix!: or BREAKING CHANGE in the body | Major (2.0.0) | 💥 Breaking Changes |
feat: | Minor (1.2.0) | 🚀 Features |
fix: | Patch (1.1.1) | 🐛 Bugfixes |
perf: | Patch | ⚡ Performance |
refactor: | Patch | ♻️ Refactoring |
docs: test: ci: chore: build: style: revert: | no release trigger | 🔧 Miscellaneous |
Examples: feat(api): notes-endpunkt, fix(ui): navbar-umbruch auf mobilgeräten, feat!: config-format v2.
The highest type since the last release determines the bump (a single feat! turns any number of fix commits into a major release). If the commits since the last tag consist only of types with no release effect (docs, chore, …), merging into main produces no new release.
Preview at any time locally: make next-version (or go run ./tools/release) shows the computed next version and the changelog section. The logic lives in tools/release (Go, with unit tests).
Preparing a release (on develop) & publishing it (on main)
Section titled “Preparing a release (on develop) & publishing it (on main)”The changelog belongs in exactly the commit that gets tagged. That’s why version and changelog are prepared before the merge, on develop — not generated afterwards in CI. This way, the commit carried over to main already has the matching changelog, main never lags behind a version, and CI needs no write token.
Step 1 — prepare on develop (packaging/prepare-release.sh, via make prepare-release):
git switch develop && git pullmake prepare-release # version derived from commits since the last tag# or force an explicit version (e.g. beta -> final):make prepare-release VERSION=1.0.0The script uses tools/release to determine the next version, writes VERSION, prepends the new section to CHANGELOG.md, and commits both as release: v<version> — Version & Changelog vorbereitet. If there are no release-relevant commits since the last tag (only docs/chore/…), nothing happens — unless an explicit version is given.
git push origin developStep 2 — create a merge request develop → main and merge it once the pipeline is green. main then automatically:
versionjob: readsNEXT_VERSIONfrom the committedVERSION, checks whetherv<version>already exists as a tag (RELEASE_NEEDED), and cuts the topmost section fromCHANGELOG.mdas the release description.binaries/packages:deb: build all platform binaries and the.debpackages with this version.releasejob (only if the tag doesn’t exist yet): uploads binaries and.debpackages to the Generic Package Registry and creates tagv<version>+ release with the changelog as the description and the files as assets.deploy:apt: rolls the.debpackages out to the repository server (see below).
No writeback, no write token: version and changelog already live in the tagged commit (and, via the merge, on develop and main too). CI writes nothing back into the repo — it only reads. The release job gets by with the automatic CI_JOB_TOKEN.
apt repository rollout (deploy)
Section titled “apt repository rollout (deploy)”On every release, the deploy:apt job rolls the built .deb packages (amd64 + arm64) out to the TechEve repository server (aptly, https://repo.techeve.de) — after that, Form Gateway can be installed and updated from the in-house repo with apt install form-gateway. The job only runs on main, and only when a release is actually due (RELEASE_NEEDED=true).
Required CI variables (for this project, already present as inherited, masked group variables from “techeve” — nothing to do):
| Variable | Example | Purpose |
|---|---|---|
REPO_URL | https://repo.techeve.de | base URL of the aptly HTTP API |
REPO_USER | gitlab-ci | basic-auth user |
REPO_PASS | (secret) | basic-auth password |
Optional: REPO_NAME (default techeve), DISTRO (default stable), GPG_KEY (default repo@techeve.de). Flow and script: packaging/publish-deb.sh. The variables are protected — the job therefore only runs on a protected branch (main); if they’re missing, publish-deb.sh aborts with a clear message. SemVer prereleases (-beta.1) are rewritten to ~beta.1 for the Debian package, so the beta sorts correctly before the later final release under apt.
Packaging (packaging/nfpm.yaml, tool nfpm): in the package, Form Gateway runs as the unprivileged systemd service form-gateway — configuration in /etc/form-gateway/config.json (owned by root, created by the postinstall script on first install with securely random jwt_secret/encryption_key), state (SQLite database) in /var/lib/form-gateway. The application itself generates and logs the admin password on the very first start (journalctl -u form-gateway) — identical to the behavior with make build/Docker. On apt purge, postremove only removes the system user, never the database or config (the submission history is the audit log, and the encryption key determines whether encrypted fields will ever be readable again).
Build locally and inspect the structure: make deb (produces bin/form-gateway_<version>_{amd64,arm64}.deb).
Dependency updates
Section titled “Dependency updates”This project does not (yet) have a dependency bot (Renovate) set up — Go modules and npm packages are updated manually with make update-deps. For setting up an automatic bot, see the reference pattern in techeve/lcm (docs/reference/ci-release.md, section “Dependency bot (Renovate)”).
Runner requirements
Section titled “Runner requirements”- A runner with a Docker executor and the tag
docker(the jobs settags: [docker]). - Internet access for the images (
golang:1-alpine,node:lts,alpine:3,registry.gitlab.com/gitlab-org/release-cli,debian:bookworm-slim) as well as Go modules/npm packages/Playwright browsers. - The release job works entirely with the automatic
CI_JOB_TOKEN— a write token is not needed, because version and changelog are committed up front on develop. The apt deploy needsREPO_URL/REPO_USER/REPO_PASS(see above, already present for this project). All other jobs need no secrets.
Roles & permissions in the project
Section titled “Roles & permissions in the project”| Role | may |
|---|---|
| Developer | push to develop, create/merge MRs (develop and main), reviews |
| Maintainer | additionally settings, manage protected branches |
| — (all) | not push directly to main — no exceptions |