Skip to content

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

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 → developpackaging/prepare-release.sh → MR → main → automatic release + apt rollout.

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

Terminal window
export GITLAB=https://gitlab.techeve.de/api/v4
export TOKEN=<personal-access-token>
Terminal window
# 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"
Terminal window
git init -b main && git add -A && git commit -m "Initial import"
git remote add origin https://gitlab.techeve.de/techeve/form-gateway.git
git push -u origin main
git switch -c develop && git push -u origin develop

Set develop as the default branch (new clones/MRs start there):

Terminal window
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”
Terminal window
# 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=0no 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):

Terminal window
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.

Terminal window
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-source job 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:

Terminal window
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 by push_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.

Defined in .gitlab-ci.yml, it runs on the runner tagged docker (Docker executor, jobs run inside images):

StageJobPurpose
checkcheck:mr-sourceMRs into main only from develop (fails otherwise)
checkcheck:commitsenforces Conventional Commits in MR pipelines
checkversionmain only: reads the (on develop prepared) VERSION and checks whether a tag already exists for it
testfrontendnpm ci + npm audit (gate) + Vite build; dist/ as artifact
testbackendgo vet + go test ./... (in-memory SQLite)
testgolangci-lintGo linters per .golangci.yml (errcheck, staticcheck, govet, ineffassign, unused)
testgovulncheckGo vulnerability scan (gate)
teste2ePlaywright against the real binary (on develop/main and in MRs)
buildbinariescross-compile: Linux amd64/arm64, Windows amd64, macOS arm64/amd64
buildpackages:deb.deb packages (amd64/arm64) with a systemd service via nfpm
releasereleasemain only: tag v<VERSION> + release with the prepared changelog (see below)
deploydeploy:aptrolls the .deb packages out to repo.techeve.de (see below)
deploydocs-build/docs-deploybuilds 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.

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.

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 typeEffect on the versionChangelog category
feat!: / fix!: or BREAKING CHANGE in the bodyMajor (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):

Terminal window
git switch develop && git pull
make 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.0

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

Terminal window
git push origin develop

Step 2 — create a merge request develop → main and merge it once the pipeline is green. main then automatically:

  1. version job: reads NEXT_VERSION from the committed VERSION, checks whether v<version> already exists as a tag (RELEASE_NEEDED), and cuts the topmost section from CHANGELOG.md as the release description.
  2. binaries / packages:deb: build all platform binaries and the .deb packages with this version.
  3. release job (only if the tag doesn’t exist yet): uploads binaries and .deb packages to the Generic Package Registry and creates tag v<version> + release with the changelog as the description and the files as assets.
  4. deploy:apt: rolls the .deb packages 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.

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

VariableExamplePurpose
REPO_URLhttps://repo.techeve.debase URL of the aptly HTTP API
REPO_USERgitlab-cibasic-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).

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)”).

  • A runner with a Docker executor and the tag docker (the jobs set tags: [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 needs REPO_URL/REPO_USER/REPO_PASS (see above, already present for this project). All other jobs need no secrets.
Rolemay
Developerpush to develop, create/merge MRs (develop and main), reviews
Maintaineradditionally settings, manage protected branches
— (all)not push directly to main — no exceptions