For this example, we have a python/django application and Docker as a containerization tool.
Goal: Bump a new version every time that a change occurs on the master branch. The bump should be executed automatically by the CI process.
- A developer creates a new commit on any branch (except
master) - A developer creates a merge request (MR) against
masterbranch - When the
MRis merged into master, the 2 stages of the CI are executed - For simplification, we store the software version in a file called
VERSION. You can use any file that you want ascommitizensupports it. - The commit message executed automatically by the
CImust include[skip-ci]in the message; otherwise, the process will generate a loop. You can define the message structure in commitizen as well.
To let GitLab CI runners push the bump commit and tag back to the repository, the runner needs write access. Two common approaches are documented below — pick whichever fits your environment best:
- SSH key — push over
git@. Requires generating a key pair, storing the private key as a CI variable, and registering the public key as a deploy key. - Project Access Token (HTTPS) — push over
https://. No SSH client is required; the runner authenticates using a token managed in the GitLab UI.
Both options assume two CI/CD variables holding the git author identity used by the bump commit:
CI_EMAILCI_USERNAME
You can create them under your project's Settings > CI/CD > Variables.
To be able to change files and push new changes with GitLab CI runners, we need to have a ssh key and configure a git user.
First, let's create a ssh key. The only requirement is to create it without a passphrase:
ssh-keygen -f deploy_key -N ""The previous command will create a private and public key under the files deploy_key and deploy_key.pub. We will use them later.
For the git user, we need an email and username. You can choose whatever you want; in this example, we choose ci-runner@myproject.com and admin, respectively.
Now, we need to create three environment variables that will be visible for the runners. They should be created in the variables section under settings/ci_cd:
Create SSH_PRIVATE_KEY, CI_EMAIL, CI_USERNAME variables, and fill them with the private_key, email and username that we have created previously.
The latest step is to create a deploy key. To do this, we should create it under the section settings/repository and fill it with the public key generated before. Check Write access allowed; otherwise, the runner won't be able to write the changes to the repository.
If you have more projects under the same organization, you can reuse the deploy key created before, but you will have to repeat the step where we have created the environment variables (ssh key, email, and username).
Tip: If the CI raise some errors, try to unprotect the private key.
-
Create a
.gitlab-ci.yamlfile that containsstagesandjobsconfigurations. You can find more info here. -
Define
stagesandjobs. For this example, we define twostageswith onejobeach one.- Test the application.
- Auto bump the version. This means changing the file/s that reflects the version, creating a new commit and git tag.
image: docker:latest
services:
- docker:dind
variables:
API_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
before_script:
- apk add --no-cache py-pip
- pip install docker-compose
stages:
- test
- auto-bump
test:
stage: test
script:
- docker-compose run -e DJANGO_ENVIRONMENT=dev your_project python manage.py test # run tests
auto-bump:
stage: auto-bump
image: python:3.10
before_script:
- "which ssh-agent || ( apt-get update -qy && apt-get install openssh-client -qqy )"
- eval `ssh-agent -s`
- echo "${SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add - > /dev/null # add ssh key
- pip3 install -U commitizen # install commitizen
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- echo "$SSH_PUBLIC_KEY" >> ~/.ssh/id_rsa.pub
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
dependencies:
- test
script:
- git remote set-url origin git@gitlab.com:discover/rentee-core.git # git configuration
- git config --global user.email "${CI_EMAIL}" && git config --global user.name "${CI_USERNAME}"
- 'exists=`git show-ref refs/heads/master` && if [ -n "$exists" ]; then git branch -D master; fi'
- git checkout -b master
- cz bump --yes # execute auto bump and push to master
- git push origin master:$CI_COMMIT_REF_NAME
- TAG=$(head -n 1 VERSION) # get the new software version and save into artifacts
- echo "#!/bin/sh" >> variables
- echo "export TAG='$TAG'" >> variables
- git push origin $TAG
only:
refs:
- master
artifacts:
paths:
- variablesSo, every time that a developer pushes to any branch, the test job is executed. If the branch is master and the test jobs succeed, the auto-bump takes place.
To be able to push using the GitLab runner, we have to set the SSH key, configure git, and finally execute the auto bump.
After merging the new changes into master, we have the final result:
If you cannot or do not want to manage SSH keys (for example, when your runners do not have an SSH client, or when SSH egress is blocked), you can let the runner push back over HTTPS using a GitLab Project Access Token. This keeps everything inside the GitLab UI — no key generation, no deploy keys.
!!! note "Availability on GitLab.com" On GitLab.com, Project Access Tokens require a Premium or Ultimate subscription. They are available with any license on GitLab Self-Managed and GitLab Dedicated. If you are on the Free tier of GitLab.com, use Option A (SSH key) or a Personal Access Token instead.
!!! note "Group / personal tokens" The same approach works with Group Access Tokens (handy when several projects share automation) and Personal Access Tokens. Project Access Tokens are usually preferred because they are scoped to a single project.
!!! warning "CI_JOB_TOKEN is not enough"
GitLab's built-in CI_JOB_TOKEN can clone/fetch the repository, but it cannot git push. You need a Project (or Group / Personal) Access Token with the write_repository scope and a role that can push to your protected branch (typically Developer or higher).
!!! info "Prerequisite"
You need at least the Maintainer role on the project to open Settings > Access Tokens and create a token. The Developer role mentioned in step 2 below is the role assigned to the bot user (i.e. the token's effective permission level when pushing), not the role you yourself need to create the token.
- In your GitLab project, go to
Settings > Access Tokens. - Create a new token:
- Name: e.g.
commitizen-bump. - Role:
Developer(or higher) — this is the bot user's role and must be allowed to push to your protected branches and tags. - Scopes: tick
write_repository(it grants both pull and push;read_repositorydoes not need to be ticked separately). - Expiration date: pick a date that suits your rotation policy.
- Name: e.g.
- Click
Create project access tokenand copy the token immediately — GitLab only shows it once.
- Open
Settings > CI/CD > Variables. - Add a new variable:
- Key:
GITLAB_TOKEN(any name works; this tutorial usesGITLAB_TOKEN). - Value: the token from Step 1.
- Tick
Maskedso it does not appear in job logs. - Tick
Protectedif your bump runs only on protected branches/tags.
- Key:
- While you are there, make sure
CI_EMAILandCI_USERNAMEvariables exist (they configure the git author for the bump commit).
If master (or main) is protected, the token's user (a bot user automatically created with the token) needs permission to push:
- Go to
Settings > Repository > Protected branches. - Make sure
Developers + Maintainers(or at least the role you assigned to the token) is allowed to push. - Do the same under
Settings > Repository > Protected tagsif you push tags such asv*.
The pipeline below mirrors the SSH example but authenticates over HTTPS using the token. It also splits the workflow so that release-only work happens on the bump commit and packaging/publishing only happens once the resulting tag is pushed:
image: python:3.10
# Run a single pipeline per push or merge request to avoid duplicate pipelines
# when a branch with an open MR is pushed.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG
stages:
- test
- bump
- release
test:
stage: test
script:
- pip install -U pip
- pip install -e .
- python -m pytest
rules:
# Skip the bump commit itself; otherwise run on every branch and MR.
- if: $CI_COMMIT_MESSAGE =~ /^bump:/
when: never
- when: on_success
bump:
stage: bump
before_script:
- pip install -U commitizen
- git config --global user.email "${CI_EMAIL}"
- git config --global user.name "${CI_USERNAME}"
# Build the authenticated push URL inline so the token is not stored in a
# CI/CD variable that is visible to project Maintainers, and is not written
# into git config on disk via a top-level `variables:` block.
- git remote set-url origin "https://oauth2:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
script:
# Re-attach HEAD to the branch (GitLab checks out a detached commit by default).
- git checkout -B "${CI_COMMIT_REF_NAME}"
- cz bump --yes
- git push origin "${CI_COMMIT_REF_NAME}"
- git push origin --tags
rules:
# Only run on the default branch, and never re-bump a bump commit.
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_MESSAGE !~ /^bump:/
release:
stage: release
script:
- pip install -U commitizen build twine
- cz changelog --dry-run "${CI_COMMIT_TAG}" > release_notes.md
- python -m build
# Upload the artifacts to your registry of choice; this is just an example.
- twine upload --non-interactive dist/*
rules:
# This job only runs on tag pipelines created by the `bump` job above.
- if: $CI_COMMIT_TAG
artifacts:
paths:
- dist/
- release_notes.mdHow the pipeline is wired:
testruns on every branch and merge request, but is skipped on the bump commit so we do not waste runners re-testing what was just released.bumponly runs on the default branch and askscommitizento compute the next version, update the version files, write the changelog, commit and tag. The push uses the token viaoauth2:${GITLAB_TOKEN}@….releaseonly runs on tag pipelines (i.e. when the tag pushed bybumparrives in GitLab). This is where you would publish artifacts, build and upload a Python package, deploy a Docker image, create a GitLab release, etc.
!!! tip "Avoiding pipeline loops"
The default cz bump commit message starts with bump:. The rules: blocks above use that prefix to skip both the test and bump jobs on the bump commit. If you customize bump_message, update the regex accordingly. You can also add [skip ci] to the bump message — see bump_message in the bump command documentation.
!!! tip "Token rotation"
Project Access Tokens expire. Set a calendar reminder before the expiration date to rotate the token and update the GITLAB_TOKEN CI/CD variable; otherwise the bump job will start failing with 403/401 errors.
!!! warning "Keep the token out of job logs"
GitLab's log masking hides the raw value of GITLAB_TOKEN, but it does not mask derived strings such as the full HTTPS URL https://oauth2:<token>@…. Avoid enabling runner debug tracing (CI_DEBUG_TRACE: "true") and avoid set -x inside the bump job — git error messages or shell traces would otherwise print the URL with the token in clear text. The example above keeps the URL inside git remote set-url so it is never assigned to a CI/CD variable.
!!! note "Self-managed instances on a non-standard port"
CI_SERVER_HOST is the hostname without protocol or port. If your GitLab instance listens on a non-standard HTTPS port (for example gitlab.example.com:8443), replace ${CI_SERVER_HOST} with ${CI_SERVER_HOST}:${CI_SERVER_PORT} (or use the CI_SERVER_FQDN predefined variable on GitLab 16.10+).


