Version control, branching en versienummering: de fundamenten van elke CI/CD-aanpak (deel 1 van 3)

Elke discussie over DevOps en CI/CD begint uiteindelijk bij dezelfde vraag: hoe gaat een team eigenlijk om met haar code? Niet de tools, niet de pipelines, maar de manier waarop mensen samen wijzigingen maken, delen en integreren. Version control, branching strategieën, versienummering en merge requests (ook wel pull requests genoemd) zijn daarin geen bijzaak — het zíjn de fundering. Zonder een heldere aanpak op dit vlak blijft elke CI/CD-pijplijn een lege huls: technisch indrukwekkend, maar in de praktijk traag, foutgevoelig en frustrerend voor het team. High performing teams hebben dan ook een duidelijke aanpak daarover afgestemd en geborgd in hun werkwijze en communicatie.

Ik schrijf dit artikel omdat ik in de praktijk keer op keer merk dat teamleden en betrokkenen — ontwikkelaars, testers, product owners, soms ook opdrachtgevers — niet altijd hetzelfde beeld hebben van deze onderwerpen. De één denkt bij “branching strategie” aan een technische keuze, de ander aan een teamafspraak, en een derde heeft er nog nooit bewust over nagedacht terwijl diegene er dagelijks mee werkt. Dat gebrek aan een gedeeld beeld is zelden iemands schuld — het zijn nu eenmaal fundamentele onderwerpen die zelden expliciet worden uitgelegd, terwijl ze wel de basis vormen van hoe een team samenwerkt.

Om het behapbaar te houden, heb ik dit onderwerp opgesplitst in drie artikelen die op elkaar voortbouwen:

  1. Dit artikel — version control, Git, branching en versienummering: de bouwstenen zelf.
  2. Deel 2 — merge requests: de plek waar kwaliteit daadwerkelijk wordt afgedwongen.
  3. Deel 3 — hoe dit alles samenkomt in een CI/CD-pipeline met GitLab, inclusief branch protection en de relatie met (ITIL) change management.

In dit eerste deel leg ik telkens uit wat een bouwsteen is, waarom je hem nodig hebt en hoe je hem toepast.

Mocht je na het lezen nog vragen hebben, schroom niet om contact met mij op te nemen: bertrand@deneveit.nl. Ik wens je alvast veel lees plezier toe.

Wat is version control, en waarom kun je er niet zonder

Wat. Version control (versiebeheer) is een systeem dat wijzigingen aan bestanden vastlegt over tijd. Het houdt bij wát er is veranderd, wíe de wijziging heeft gemaakt, wannéér dat gebeurde en — via zogenaamde commit-berichten — wáárom. Die geschiedenis maakt het mogelijk om terug te gaan naar een eerdere versie, te achterhalen hoe code is ontstaan, en wijzigingen van meerdere mensen naast elkaar te laten bestaan zonder dat werk verloren gaat.

Waarom. Zonder versiebeheer werk je op goed geluk: bestanden overschrijven elkaar, wijzigingen zijn niet te herleiden, en teruggaan naar “de vorige werkende versie” is vaak gewoon niet mogelijk. Version control lost dat structureel op:

  • Het voorkomt dat werk per ongeluk verloren gaat.
  • Het maakt parallelle ontwikkeling mogelijk — meerdere mensen werken tegelijk aan dezelfde codebase.
  • Het zorgt voor traceerbaarheid en verantwoording: elke wijziging is te herleiden naar een specifiek moment, een persoon en een reden — denk aan een referentie naar een work-item of story in de teambacklog (de planning- en controltool waarmee een team het werk en de status ervan gestructureerd bijhoudt en overzicht houdt op wat er speelt, zoals Jira of GitLab Issues).
  • Het is de basis waarop CI/CD überhaupt kan bestaan. Geen enkele pipeline kan “automatisch testen en uitrollen” zonder een eenduidige, herleidbare versie van de code om op te draaien.

Dit geldt niet alleen voor applicatiecode. Infrastructuurcode (denk aan Terraform of Ansible), applicatieconfiguratie/instellingen, testautomatisering én policy-as-code (PaC) — bijvoorbeeld OPA/Rego-regels die automatisch afdwingen aan welke eisen infrastructuur of pipelines moeten voldoen, zoals “elke storage-bucket moet versleuteld zijn” — horen in dezelfde discipline thuis. Veel teams beheren deze in aparte repositories. Een repository (letterlijk: opslagplaats) is de plek waar de code, inclusief de volledige geschiedenis van alle wijzigingen, wordt opgeslagen en beheerd. In de praktijk is dat meestal een project binnen een platform zoals GitLab — daar gaan we in deel 3 van deze serie uitgebreid op in. Dat meerdere, aparte repositories bestaan is des te meer reden om als team een expliciete, gedeelde afspraak te hebben over hoe je met branches en merges omgaat — anders ontstaan er per repository andere gewoontes, en dat werkt verwarring en fouten in de hand.

Git in de praktijk: hoe werkt het echt

Wat. Git is het meest gebruikte gedistribueerde versiebeheersysteem. “Gedistribueerd” betekent dat iedere ontwikkelaar een volledige kopie van de repository heeft, inclusief de complete geschiedenis. Dat maakt offline werken mogelijk, en branchen en samenvoegen (mergen) is snel omdat alles lokaal gebeurt.

Hoe. De dagelijkse workflow bestaat uit een vaste set stappen:

git clone https://repo-url.git           # kopie van de remote repository ophalen
git pull                                 # laatste wijzigingen ophalen
git checkout -b feature/mijn-wijziging   # maak een feature branch aan waarin je aanpassingen maakt
git add bestand.txt                      # wijziging klaarzetten (staging)
git commit -m "Fix VAT calc."            # wijziging vastleggen met uitleg
git push                                 # wijziging naar de remote repository sturen

Een commit-bericht is meer dan een formaliteit — het is documentatie voor je toekomstige collega’s (en jezelf). Een goed commit-bericht:

  • gebruikt de tegenwoordige tijd, gebiedende wijs, liefst in de Engelse taal (“Add”, niet “Added” of “Adding”);
  • heeft een korte, krachtige eerste regel (max. ~50 tekens);
  • laat een lege regel volgen, gevolgd door een toelichting op wat en waarom — niet hoe, dat staat al in de code;
  • verwijst altijd naar het bijbehorende backlog-item, zodat de wijziging traceerbaar is naar de reden waaróm het werk is opgepakt. Dit laatste helpt ook om geautomatiseerd (ITIL) change management efficiënt te realiseren en vaak ook nodig door compliance/ wet- en regelgeving— daar kom ik in deel 3 op terug.

Voorbeeld: BCK-87: Fix TAX calculation feature

Branching: parallel werken zonder elkaar in de weg te zitten

Wat. Een branch, vertakking, is in de kern niets meer dan een lichtgewicht verwijzing naar een commit. Het is geen kopie van de hele codebase — het is een naam die naar een punt in de geschiedenis wijst, waarna nieuwe commits die naam laten meebewegen.

Waarom. Branches bestaan om werk te isoleren totdat het klaar is. Dat is precies wat je nodig hebt voor:

  • parallelle ontwikkeling zonder dat wijzigingen elkaar constant in de weg zitten;
  • experimenteren en feature-ontwikkeling zonder de hoofdbranch te verstoren;
  • gerichte, feature-specifieke tests uitvoeren vóórdat iets de hoofdbranch bereikt (ook wel main branch of master genoemd);
  • omgeving-specifieke uitrol (bijvoorbeeld: deze branch gaat naar staging, die naar productie);
  • een helder, herkenbaar werkproces rond elke wijziging.

Hoe. De meest gebruikte Git-commando’s voor branches:

git branch branch-naam          # nieuwe branch aanmaken
git checkout branch-naam        # naar een branch overschakelen
git switch branch-naam          # idem, met nieuwere Git-syntax
git checkout -b branch-naam     # aanmaken én overschakelen in één stap
git branch                      # alle branches tonen
git merge branch-naam           # branch samenvoegen met je huidige branch
git branch -d branch-naam       # branch verwijderen na succesvolle merge

Branching strategieën: welke aanpak past bij jouw team

Er bestaan meerdere branching strategieën, en een veelgemaakte denkfout is dat “eenvoudiger” ook “makkelijker” betekent. Het tegendeel is vaak waar: de meest minimale modellen, zoals Trunk-Based Development, vragen juist de hoogste teamdiscipline en automatiseringsvolwassenheid.

De juiste strategie bij de volwassenheid van je team

Teams die nog in opbouw zijn qua CI/CD-volwassenheid kiezen vaak voor een gestructureerde aanpak zoals GitFlow, en dat is geen tekortkoming — het is verstandig risicomanagement:

  • Duidelijke regels voorkomen fouten.
  • De scheiding tussen branches creëert natuurlijke controlemomenten.
  • QA kan op release-branches gefaseerd handmatig testen.
  • Er is ruimte voor een combinatie van handmatige en geautomatiseerde controles.

GitFlow creëert in feite een wachtruimte tussen ontwikkeling en productie. Die ruimte geeft mensen tijd om code te controleren — met testers, security-reviewers, of gewoon een extra paar ogen. Dat is precies waarom veel teams die van een meer traditionele werkwijze komen, met GitFlow beginnen: het compenseert voor automatisering die er nog niet is. De valkuil is hier uiteraard uitstel van controles en laat de aanpassingen samenvoegen/mergen met de hoofdbranch. Hoe langer je wacht, hoe complexer en risicovoller dat wordt.

Teams met een hoge automatiseringsgraad kiezen vaker voor Trunk-Based Development, waarbij:

  • code-kwaliteitscontroles volledig geautomatiseerd op elke commit draaien, zonder uitzondering;
  • security-scans eveneens automatisch meelopen;
  • een testdekking van 80%+ met sterke unit-, integratie- en end-to-end-tests de norm is;
  • er letterlijk geen tijd is voor handmatig testen vóórdat code de hoofdbranch bereikt — de geautomatiseerde tests móéten het probleem vinden, niemand anders doet het meer.

Verdieping: Test Driven Development als motor achter Trunk-Based

Trunk-Based Development belooft “geen tijd voor handmatig testen” — maar dat is alleen veilig als de geautomatiseerde tests daadwerkelijk vertrouwen verdienen. Dat is precies waar Test Driven Development (TDD) om de hoek komt kijken. TDD is geen testmethode achteraf, maar een manier van code schríjven waarbij de test vóór de implementatie wordt gemaakt, volgens de bekende red-green-refactor-cyclus:

  1. Red — schrijf eerst een test die het gewenste gedrag beschrijft. Deze faalt per definitie, want de functionaliteit bestaat nog niet.
  2. Green — schrijf de minimale hoeveelheid code die nodig is om de test te laten slagen. Niet meer, niet minder.
  3. Refactor — verbeter de structuur van de code zonder het gedrag te wijzigen; de test is je vangnet dat garandeert dat je niets breekt.

Het verschil met “tests achteraf schrijven” is groter dan het lijkt. Tests die na de code worden geschreven, bevestigen vaak alleen wat de code toevallig al doet — inclusief de aannames en blinde vlekken van de schrijver(s). Tests die er eerst zijn, dwingen je om na te denken over het gewenste gedrag vóórdat de implementatie die keuze voor je maakt. Het resultaat is niet alleen hogere testdekking, maar ook beter testbare, losser gekoppelde code — want code die je eerst vanuit een test moet kunnen aanroepen, wordt vanzelf minder verweven met alles eromheen. Een win/win situatie en mag duidelijk zijn geworden dat TDD een andere werkhouding/cultuur teweeg brengt.

Concreet ziet dat er zo uit — een kortingsberekening die nog niet bestaat:

# STAP 1 — RED: schrijf eerst de test. Deze faalt, want calculate_price() bestaat nog niet.
def test_discount_of_10_percent_on_100_euro_is_90_euro():
    result = calculate_price(base_price=100, discount_percentage=10)
    assert result == 90

# STAP 2 — GREEN: schrijf precies genoeg code om de test te laten slagen. Niet meer.
def calculate_price(base_price, discount_percentage):
    return base_price - (base_price * discount_percentage / 100)

# STAP 3 — REFACTOR: nu de test slaagt, verbeter je de code zelf. De test blijft groen
# en bewijst dat het gedrag niet is veranderd — alleen de kwaliteit ervan.
def calculate_price(base_price: float, discount_percentage: float) -> float:
    if not 0 <= discount_percentage <= 100:
        raise ValueError("Discount percentage must be between 0 and 100")
    return base_price * (1 - discount_percentage / 100)

Merk op wat er hier gebeurt: pas in stap 3 komt de validatie op een ongeldig kortingspercentage erbij. Die eis kwam niet uit het niets — die ontdek je vaak juist tijdens het refactoren, omdat je door de test gedwongen wordt na te denken over wat er nog meer fout kan gaan. Dat is het mechanisme waarmee TDD dekking laat meegroeien met de code, in plaats van dat je achteraf probeert te “raden” welke tests er nog missen.

Voor branching betekent dit concreet: een team dat overstapt van GitFlow naar Trunk-Based zonder eerst TDD (of op zijn minst test-first denken) serieus te introduceren, zal merken dat “geen tijd voor handmatig testen” een risico wordt in plaats van een voordeel. De testdekking moet het gevolg zijn van hoe je codeert, niet een percentage dat je achteraf probeert te halen.

Verdieping: feature flags — meer dan een aan/uit-schakelaar

Trunk-Based werkt ook onlosmakelijk samen met feature flags (of ook wel feature toggles genoemd): functionaliteit die nog niet af is, wordt verborgen achter een schakelaar in plaats van achtergehouden in een aparte branch. Dat ontkoppelt twee dingen die anders aan elkaar vastzitten: het moment van deployen (code naar productie brengen) en het moment van releasen(functionaliteit zichtbaar maken voor gebruikers). Zo kan code snel integreren zonder dat incomplete features live zichtbaar worden.

Er zijn grofweg vier typen flags, elk met een ander doel:

  • Release toggles — verbergen incomplete functionaliteit in productie totdat die klaar is. Dit is het type dat Trunk-Based mogelijk maakt.
  • Ops toggles — een noodrem voor operations: bij problemen kun je een zware of instabiele functionaliteit razendsnel uitzetten, zonder rollback.
  • Experiment toggles — voor A/B-testen, waarbij verschillende gebruikersgroepen verschillende varianten zien.
  • Permission toggles — functionaliteit die alleen zichtbaar is voor specifieke gebruikers of klanten, bijvoorbeeld bèta-toegang.

GitLab heeft feature flags als ingebouwde functionaliteit, maar losstaande tools zoals LaunchDarkly of het open-source Unleash worden ook veel gebruikt — vooral wanneer je flags onafhankelijk van een deployment wilt kunnen aanpassen.

Eén valkuil verdient aandacht: flags die blijven bestaan nadat een feature volledig is uitgerold, worden flag-schuld (~technical debt). Elke actieve flag betekent extra codepaden die getest en onderhouden moeten worden. Een goede gewoonte is daarom om bij het aanmaken van een flag meteen een opruimmoment of -ticket/story vast te leggen, zodat de flag verdwijnt zodra de rollout compleet is.

De praktische les blijft: begin met de structuur die past bij je huidige automatiseringsniveau, en werk bewust toe naar minder structuur naarmate je automatisering (tests, security-scans, kwaliteitspoorten) volwassener wordt. Structuur afbouwen zónder dat de automatisering meegroeit, is de snelste weg naar een instabiele hoofdbranch en ellende. Het mag duidelijk zijn dat het team met regelmaat haar werkwijze evalueert, streeft naar continuous improvement en minimale technical debt.

Versienummering: MAJOR.MINOR.PATCH

Er is één ding dat in veel teams “gewoon zo gegroeid is” en zelden bewust wordt afgesproken: hoe geef je een release eigenlijk een naam? De industriestandaard hiervoor heet Semantic Versioning, kortweg SemVer, en is als open specificatie vastgelegd op semver.org.

Wat. Een versienummer volgens SemVer heeft de vorm MAJOR.MINOR.PATCH, bijvoorbeeld 2.4.1. Elk van de drie cijfers heeft een vaste, afgesproken betekenis:

  • MAJOR gaat omhoog bij een incompatibele wijziging — iets dat een consument van jouw software (een andere service, een andere ontwikkelaar, een geïntegreerde klant) kan breken.
  • MINOR gaat omhoog bij nieuwe functionaliteit die achterwaarts compatibel is — er komt iets bij, niets bestaands verandert van gedrag.
  • PATCH gaat omhoog bij een bugfix die geen invloed heeft op het gedrag naar buiten toe — je repareert iets dat niet werkte zoals bedoeld, zonder de API te wijzigen.

Een belangrijke regel die vaak over het hoofd wordt gezien: zodra je MAJOR verhoogt, gaan MINOR én PATCH terug naar 0. Zodra je MINOR verhoogt, gaat PATCH terug naar 0. Dus 1.4.22.0.0 bij een breaking change, niet 2.4.2.

Waarom. Een versienummer is geen boekhoudkundig detail — het is communicatie. Het vertelt iedereen die van jouw software afhankelijk is, zónder de changelog te hoeven lezen, hoeveel risico een upgrade met zich meebrengt:

Wat er verandert Voorbeeld Versiesprong
Breaking change Een API-endpoint verandert van vorm of verdwijnt 1.4.22.0.0 (MAJOR)
Nieuwe functionaliteit, compatibel Een nieuw, optioneel veld wordt toegevoegd 1.4.21.5.0 (MINOR)
Bugfix, geen gedragswijziging Een foutieve BTW-berekening wordt gerepareerd 1.4.21.4.3 (PATCH)

Een team dat automatisch dependencies bijwerkt (denk aan Dependabot of Renovate), leunt volledig op deze afspraak: een PATCH- of MINOR-update mag je in principe zonder angst binnenhalen, een MAJOR-update vraagt bewuste aandacht. Zodra een team zich niet aan de afspraak houdt — een breaking change “stiekem” in een patch-release stopt — is het hele systeem zijn waarde kwijt, voor iedereen die erop vertrouwt.

Voor pre-releases en tussentijdse builds kent SemVer twee optionele toevoegingen:

  • Pre-release labels, zoals 2.0.0-beta.1 of 2.0.0-rc.2 — een versie die nog niet als definitief geldt en een lagere precedentie heeft dan de “kale” versie.
  • Build-metadata, zoals 2.0.0+20260702 — extra informatie (bijvoorbeeld een build- of commit-hash) die niets zegt over compatibiliteit en genegeerd wordt bij het vergelijken van versies.

Hoe (in de praktijk, met Git en GitLab). Een release markeer je in de basis met een Git-tag. Traditioneel doet een ontwikkelaar dit handmatig op de command-line:

git tag -a v1.5.0 -m "Add multi-currency pricing support"
git push origin v1.5.0

GitLab herkent tags die aan een patroon voldoen (bijvoorbeeld v*) en kan daar automatisch een release-pipeline aan koppelen — inclusief het genereren van release notes en het publiceren van artefacten. Net als bij branches kun je ook tags beschermen via protected tags. Daarmee bepaal je wie zo’n officiële versietag mag “uittikken” — dat wil zeggen: de tag daadwerkelijk aanmaken en pushen, waarmee een commit definitief als een release wordt bestempeld. Met protected tags stel je in dat dit alleen mag vanuit een bepaalde GitLab-rol, bijvoorbeeld Maintainer of Owner, of vanuit een specifieke rol zoals “release manager”. Een gewone Developer kan dan nog wel code schrijven en mergen, maar niet zelfstandig een officiële release uittikken. Dat voorkomt dat iemand per ongeluk (of expres) een v2.0.0-tag zet op code die daar nog niet klaar voor is — het aanmaken van een release-tag wordt zo net zo’n bewust, gecontroleerd moment als de productie-uitrol zelf. In deel 3 laat ik zien hoe je dit soort bewuste, handmatige momenten ook in de pipeline zelf inbouwt.

Hoewel dit handmatige proces prima werkt, kiezen moderniserende teams er steeds vaker voor om dit volledig te automatiseren. Ontwikkelaars typen het git tag-commando dan nooit meer zelf. In plaats daarvan grijpt het releaseproces direct terug op de commit-berichten waar we het eerder over hadden.

Teams gebruiken in dat geval Conventional Commits — een lichte conventie voor commit-berichten — om de versiesprong automatisch door de pipeline te laten bepalen op basis van de gemaakte code:

fix: correct rounding error in tax calculation        → PATCH bump
feat: add multi-currency pricing support               → MINOR bump
feat!: remove deprecated /v1/orders endpoint            → MAJOR bump

BREAKING CHANGE: /v1/orders is verwijderd, gebruik /v2/orders

Tools zoals semantic-release draaien mee als een job in de GitLab-pipeline. Ze lezen deze commit-berichten uit bij elke merge naar main, bepalen volautomatisch of de wijzigingen een MAJOR-, MINOR- of PATCH-release rechtvaardigen, genereren de changelog én zetten namens het team de juiste Git-tag in de repository. Dit haalt de menselijke discussie over “is dit nu een 1.5.0 of een 1.4.3?” volledig uit de workflow en voorkomt dat er per ongeluk release-afspraken worden geschonden.

Tot slot van dit deel

Met version control, Git, branching en versienummering staat de basis. In deel 2 ga ik dieper in op merge requests: het moment waarop al deze afspraken samenkomen en waar kwaliteit daadwerkelijk wordt afgedwongen, niet alleen besproken. In deel 3 leg ik de koppeling met CI/CD, branch protection en ITIL change management.

Bij vragen, schroom niet om contact met mij op te nemen: bertrand@deneveit.nl

Disclaimer: Dit artikel is uitsluitend bedoeld voor informatieve en educatieve doeleinden. De beschreven werkwijzen en voorbeelden weerspiegelen ervaringen opgedaan in de praktijk. De genoemde tools en platforms (waaronder GitLab, Terraform, LaunchDarkly en Unleash) zijn gekozen ter illustratie van het onderliggende principe; de auteur is niet gelieerd aan welke van deze partijen dan ook. Codevoorbeelden zijn vereenvoudigd weergegeven en dienen ter illustratie, niet als kant-en-klare implementatie of template. Voor toepassing in uw specifieke situatie wordt professioneel advies aanbevolen. Dit artikel is tot stand gekomen met assistentie van een AI-systeem. De technische implementatie, inhoudelijke keuzes, conclusies en eindredactie zijn volledig van de auteur.

Photo by John on Unsplash