Building a custom GitLab CI update bot

At work we have our own Grafana Kubernetes operator for deploying Grafana instances on OpenShift clusters. Since we are running the operator for our users, they cannot freely choose the version of Grafana themselves, but rather need to go through us. GrafanaLabs frequently releases new version of their software (awesome!) and we want to make those versions available as soon as possible to our users.

Long story short, we want to update our operator every time a new Grafana version is released. On GitHub one could use the recently acquired Dependabot for this purpose, but I’m not aware of a similar tool for GitLab. So I decided to write some automation around this for our use-case.

#  Step 1: Fetch latest version and compare

The first step is obtaining the version string of latest available Grafana release. This can be done through the Github API:

1
2
3
4
5
6
all_releases="$(curl -s \
  -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/repos/grafana/grafana/releases)"
latest_release="$(echo "$all_releases" | jq '.[0].tag_name' | tr -d '"')"
# trim leading "v"
latest_release="${latest_release#v}"

Now the latest_release variable contains a string like 8.1.2. Next we obtain the version currently pinned in our repository. From where exactly this information comes depends on the repository structure, but generally speaking we want to extract a pattern like x.y.z from one of the files. Unfortunately, while GNU grep has a regex engine for matching lines, it does not support printing only the regex capture group. Instead, we can use sed for this purpose, as outlined in this StackOverflow answer.

1
2
3
# Look for the line `  tag: "x.y.z"` in values.yaml and print `x.y.z` only
version_file=helm-charts/grafana/values.yaml
current_release="$(sed -nr 's/  tag: "([0-9]+\.[0-9]+\.[0-9]+)"/\1/p' "$version_file")"

We have the current version and the latest version, so next we need to compare the two. Credits to Max Rittmüller for the following idea of comparing two semantic versions in Bash. I like it because it is very readable and easy to extend (for example if the upstream software has version suffixes like -dev).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
is_up_to_date() {
    # Replace dots with spaces
    current="$(echo $1 | tr . ' ')"
    latest="$(echo $2 | tr . ' ')"

    # So, we just need to extract each number like this:
    current_patch=$(echo $current | awk '{print $3}')
    current_minor=$(echo $current | awk '{print $2}')
    current_major=$(echo $current | awk '{print $1}')

    latest_patch=$(echo $latest | awk '{print $3}')
    latest_minor=$(echo $latest | awk '{print $2}')
    latest_major=$(echo $latest | awk '{print $1}')

    # And now, we can simply compare the variables, like:
    [ $latest_major -gt $current_major ] && return 1
    [ $latest_minor -gt $current_minor ] && return 1
    [ $latest_patch -gt $current_patch ] && return 1

    return 0
}

if is_up_to_date "$current_release" "$latest_release"; then
    echo "Up-to-date"
else
    echo "$latest_release"
fi

Great, now we have a script that we can use to detect whether we need to update our Grafana operator.

#  Step 2: Update and commit

In the second step we will update version string stored in our repository. If necessary, this step can also be used to update dependencies, bump other version numbers, update documentation etc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Set valid username so git doesn't complain
git config user.email "update.bot@example.com"
git config user.name "Update Bot"

BRANCH_NAME="update-${CI_PROJECT_NAME}-${CI_PIPELINE_ID}"
git checkout -b "${BRANCH_NAME}"

# Update Grafana version
sed -i "s/^  tag: \".*\"$/  tag: \"${latest_release}\"/" helm-charts/grafana/values.yaml
git add helm-charts/grafana/values.yaml

# [...] more update tasks

git commit -F - <<EOF
Bump Grafana version to ${latest_release}

https://grafana.com/docs/grafana/latest/release-notes/release-notes-$(echo "$latest_release" | tr . -)/
EOF

The script and comments are fairly self explanatory. Once nice aspect in this case is the fact that we can rely on GrafanaLabs always publishing release notes under the same URL pattern, thus we can helpfully include this information in the commit and merge request – which we need to create next.

#  Step 3: Push code and create MR

In this final step, we push the newly created commit to the GitLab repository and create a merge request for the branch. To be able to do this, we need to create a personal access token (PAT) in the GitLab account and add it to the CI/CD variables section of the repository. The PAT needs the api permission for creating the merge request and write_repository permission for pushing code to the repository. In my case, I put the PAT into the CI variable UPDATE_BOT_AUTH_TOKEN. Then, the following snippet can be used to publish the changes. All variables beginning with CI_ and GITLAB_ are automatically injected by GitLab, see Predefined environment variables).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Overwrite origin remote so we can push there (the default CI token can only read)
git remote set-url origin "https://update-bot:${UPDATE_BOT_AUTH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"

export GITLAB_TOKEN="$UPDATE_BOT_AUTH_TOKEN"
export GITLAB_HOST="$CI_SERVER_HOST"
glab mr create \
     --assignee "$GITLAB_USER_LOGIN" \
     --fill \
     --label update,bot \
     --push \
     --remove-source-branch \
     --target-branch master \
     --yes

I was first considering to manually fiddle with the GitLab API, but luckily found the awesome glab CLI client for GitLab to be a much better (and maintained!) alternative. It also supports managing GitLab issues, pipelines, labels, release and repositories.

GitLab Merge Request

#  Step 4: Combine everything in a scheduled pipeline

At this point we can combine all the code snippets into a coherent script and run it in a GitLab CI/CD pipeline.

.gitlab-ci.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
stages:
  - update

Check for updates and create MR:
  stage: update
  image: example.com/image-with-git-curl-jq-glab:latest
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "push"'
      when: manual
      allow_failure: true # makes this job non-blocking
  script:
    # Set valid username so git doesn't complain
    - git config user.email "update.bot@example.com"
    - git config user.name "Update Bot"
    # Overwrite origin remote so we can push there (the default CI token can only read)
    - git remote set-url origin "https://update-bot:${UPDATE_BOT_AUTH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
    - ./check-version-and-create-mr.sh

check-version-and-create-mr.sh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#!/bin/bash
# This script will check if a more recent Grafana release exists upstream,
# update the Grafana version specified in this repository
# and create a merge request for the change.

# Bash 'Strict Mode'
# http://redsymbol.net/articles/unofficial-bash-strict-mode
# https://github.com/xwmx/bash-boilerplate#bash-strict-mode
set -o nounset
set -o errexit
set -o pipefail
IFS=$'\n\t'

all_releases="$(curl -s \
  -H "Accept: application/vnd.github.v3+json" \
  https://api.github.com/repos/grafana/grafana/releases)"
latest_release="$(echo "$all_releases" | jq '.[0].tag_name' | tr -d '"')"
# trim leading "v"
latest_release="${latest_release#v}"

# use sed instead of grep because it just prints the regex capture group (instead of the whole line)
current_release="$(sed -nr 's/  tag: "([0-9]+\.[0-9]+\.[0-9]+)"/\1/p' helm-charts/grafana/values.yaml)"

is_up_to_date() {
    # Replace dots with spaces
    current="$(echo $1 | tr . ' ')"
    latest="$(echo $2 | tr . ' ')"

    # So, we just need to extract each number like this:
    current_patch=$(echo $current | awk '{print $3}')
    current_minor=$(echo $current | awk '{print $2}')
    current_major=$(echo $current | awk '{print $1}')

    latest_patch=$(echo $latest | awk '{print $3}')
    latest_minor=$(echo $latest | awk '{print $2}')
    latest_major=$(echo $latest | awk '{print $1}')

    # And now, we can simply compare the variables, like:
    [ $latest_major -gt $current_major ] && return 1
    [ $latest_minor -gt $current_minor ] && return 1
    [ $latest_patch -gt $current_patch ] && return 1

    return 0
}

if is_up_to_date "$current_release" "$latest_release"; then
    echo "Up-to-date"
    exit 0
else
    echo "Found newer version: '$latest_release'"
fi

BRANCH_NAME="update-${CI_PROJECT_NAME}-${CI_PIPELINE_ID}"
git checkout -b "${BRANCH_NAME}"

# Update Grafana version
sed -i "s/^  tag: \".*\"$/  tag: \"${latest_release}\"/" helm-charts/grafana/values.yaml
git add helm-charts/grafana/values.yaml

# [...] more update tasks

git commit -F - <<EOF
Bump Grafana version to ${latest_release}

https://grafana.com/docs/grafana/latest/release-notes/release-notes-$(echo "$latest_release" | tr . -)/
EOF

export GITLAB_TOKEN="$UPDATE_BOT_AUTH_TOKEN"
export GITLAB_HOST="$CI_SERVER_HOST"
glab mr create \
     --assignee "$GITLAB_USER_LOGIN" \
     --fill \
     --label update,bot \
     --push \
     --remove-source-branch \
     --target-branch master \
     --yes

exit 0

Note: I initially had trouble with glab due to the following error message. This turned out to be caused by an old version of Git (~1.8) and was resolved by using a base image with a more recent version of Git (~2.28).

fatal: ambiguous argument 'origin/master...update-12345': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'

Finally, after we put all the pieces in place, we set up a scheduled pipeline for it. For our use-case, checking for updates once a day is totally sufficient, but this will vary depending on the upstream you are tracking.

GitLab CI Scheduled Pipeline

#  Outlook

This post went through the details of building a custom update bot with GitLab CI, similar to Dependabot. Since this automation is a common requirement in many repositories, it makes sense to build a more general-purpose solution for this, which can be reused across different repositories. In a future post I will explore which are the right abstractions (version checking, updating etc.) for building such a solution.

Until then, happy updating!

#  References