Making a deb package with Jenkins and CMake

By Jean Hertel, 09.08.18

deb-package , cmake , cpp , jenkins

A few days ago I decided to add the creation of debian packages automatically to each commit through Jenkins. My first reaction was to read the official Debian documentation on how to create a package. After several frustrating hours reading the documentation, I started trying to generate a script that would do everything that was needed. Like every good programmer I looked for help on the internet to find that there was little or no documentation on how to do a debian package without being in a debian distribution. For this reason I decided to write this article, hoping that other people will be able to generate the packages more easily.

First and foremost, we need to understand the environment we have. I’m doing this tutorial for adriconf, my software for configuring GPUs for Linux. This software is written in C ++ and uses CMake. For the builds I have a server running Jenkins and using pipeline with Jenkinsfile file.

The first step is to create our Jenkinsfile, instructing it to build our project:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                echo 'Running build stage'
                sh 'mkdir build-dir'
                dir('build-dir'){
                    sh 'cmake ..'
                    sh 'cmake --build . --target adriconf'
                    sh 'make translations'
                }
            }
        }
    }
}

With this first step we can build our package as well as generate the translation files.

Now we need to understand the structure of a binary debian package. In general they have the following structure:

adriconf
├── DEBIAN
│   └── control
└── usr
    ├── bin
    │   └── adriconf
    └── share
        ├── doc
        │   └── adriconf
        │       ├── changelog.gz
        │       └── copyright
        └── locale
            ├── en
            │   └── LC_MESSAGES
            │       └── adriconf.mo
            ├── hr
            │   └── LC_MESSAGES
            │       └── adriconf.mo
            └── pt_BR
                └── LC_MESSAGES
                    └── adriconf.mo

The DEBIAN/ folder has control files. This is where we will put the pre and post installation scripts as well as the control file. In the control file we define the metadata of the package, such as version, author, dependencies, etc.

The usr/ folder will contain the package structure installed on the user’s system and therefore must have all folders and subfolders. In our case, we will install the adriconf executable in the /usr/bin/ folder, the translations in the /usr/share/locale/ folder and the documentation of the application in /usr/share/doc/adriconf/ folder.

Let’s now look at how to build each part of this tree.

Generating the binary

This should be the easy part. After compiling our program with cmake we just need to create a directory and paste the executable into it. For this we will add another stage to our build:

stage('DebPackage') {
    steps {
        sh 'mkdir -p usr/bin/'
        sh 'cp build-dir/adriconf usr/bin/adriconf'
    }
}

Note: The usr/bin/ directory is created within our workspace.

Generating the translations

Since we will not have control over how many translations exist, we will have to calculate this at the time of the build, and generate the respective directories. The way I found to do this was to generate the translations, list them and finally create the directories for each one. This sequence of commands does the job:

cd build-dir
for fn in *.gmo; do
TRLANG=`echo "$fn" | cut -d"." -f1`;
mkdir -p ../usr/share/locale/$TRLANG/LC_MESSAGES/;
cp $fn ../usr/share/locale/$TRLANG/LC_MESSAGES/adriconf.mo;
done

I ended up running into a problem right now: how do I save environment variables to jenkins? I did not find an easy answer, so I chose to make this whole command a unique call to the shell, adding to our build the following:

dir('build-dir') {
    sh 'for fn in *.gmo; do TRLANG=`echo "$fn" | cut -d"." -f1`; mkdir -p ../usr/share/locale/$TRLANG/LC_MESSAGES/; cp $fn ../usr/share/locale/$TRLANG/LC_MESSAGES/adriconf.mo; done'
}

These two files are required and have their own syntax. I will not detail exactly how to write them, because the rules in the official documentation are very clear. What’s worth mentioning here is the way we add this to the build. I already had both files generated within the DEBIAN directory, so I just needed to copy and zip them to the proper folders:

sh 'mkdir -p usr/share/doc/adriconf/'
sh 'cp DEBIAN/copyright usr/share/doc/adriconf/'
sh 'gzip -n9 DEBIAN/changelog'
sh 'cp DEBIAN/changelog.gz usr/share/doc/adriconf/'

Note: The changelog must be in zip format and must have the -n option, otherwise Lintian will complain about incorrect timestamps.

The control file

This file has its own syntax and is also documented on the official Debian website. The interesting thing here is the size of the package and the version, which we must generate dynamically.

The syntax of my control file looks like this:

Package: adriconf
Version: __VERSION__
Section: utils
Priority: optional
Architecture: amd64
Installed-Size: __BINARY_SIZE__
Maintainer: Jean Hertel <jean.hertel@hotmail.com>
Homepage: https://github.com/jlHertel/adriconf
Depends: libgl1-mesa-glx, libgtkmm-3.0, libboost-locale, libxml++2.6, libx11, libdrm2, libpciaccess0, intltool, libc6
Description: GUI Tool used to configure OpenGL drivers
    You can use it to optimize game settings or even workaround issues with them

For the size of the package we can calculate using the du -s usr/ command. So we added to our build:

sh 'binarySize=$(du -s usr/ | cut -f1); replaceString="s/__BINARY_SIZE__/"$binarySize"/"; sed -i $replaceString DEBIAN/control'

Finally for the package version I have chosen to create a file called VERSION at the root of the project, so I just need to change it. The content is just the version number. To use it include in the build:

sh 'versionStr=$(cat VERSION); sed -i "s/__VERSION__/"${versionStr}"/" DEBIAN/control'

Generating the package

To generate the package we only need the ar and tar tool. In addition to the files already mentioned, we will have a file called debian-binary, which should have the string 2.0. In addition to this mandatory file, I would like to be able to dynamically generate the package version. For this reason, we will use the VERSION file again, to always have a correct version. To generate the package add the following to our build:

sh 'tar czf data.tar.gz usr/'
dir('DEBIAN') {
    sh 'tar czf ../control.tar.gz control'
}
sh 'echo 2.0 > debian-binary'
sh 'versionStr=$(cat VERSION);ar r adriconf-$versionStr.deb debian-binary control.tar.gz data.tar.gz'

Fixing lintian issues

Finally we have a package ready, but if we use lintian we will realize a series of errors in it. To solve them, let’s add a few more things to our build:

dir('build-dir') {
    sh 'strip --strip-unneeded -o adriconf-stripped adriconf'
}

With this we will remove all debug symbols from the binary, while keeping an original copy of it, which can be useful for debugging.

Lintian will also complain about incorrect permissions on files. These suprised me, because the executable will have to be owned by the root user, but Jenkins can not run as root. How do we do it then?

For this case the simplest is to first correct the permission with chmod (which was not necessary in my case). For the root user, we can use a command called fakeroot that will ‘pretend’ that we are root, so the commands ar and tar store the root username. Change the creation of the packages to the following:

sh 'fakeroot tar czf data.tar.gz usr/'
dir('DEBIAN') {
    sh 'fakeroot tar czf ../control.tar.gz control'
}
sh 'echo 2.0 > debian-binary'
sh 'versionStr=$(cat VERSION);fakeroot ar r adriconf-$versionStr.deb debian-binary control.tar.gz data.tar.gz'

The final build script:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                echo 'Running build stage'
                sh 'mkdir build-dir'
                dir('build-dir'){
                    sh 'cmake ..'
                    sh 'cmake --build . --target adriconf'
                    sh 'make translations'
                }
            }
        }
    }

    stage('DebPackage') {
        sh 'mkdir -p usr/bin/'
        dir('build-dir') {
            sh 'strip --strip-unneeded -o adriconf-stripped adriconf'
        }

        sh 'cp build-dir/adriconf-stripped usr/bin/adriconf'
        sh 'mkdir -p usr/share/locale/'

        dir('build-dir') {
            sh 'for fn in *.gmo; do TRLANG=`echo "$fn" | cut -d"." -f1`; mkdir -p ../usr/share/locale/$TRLANG/LC_MESSAGES/; cp $fn ../usr/share/locale/$TRLANG/LC_MESSAGES/adriconf.mo; done'
        }

        sh 'mkdir -p usr/share/doc/adriconf/'
        sh 'cp DEBIAN/copyright usr/share/doc/adriconf/'
        sh 'gzip -n9 DEBIAN/changelog'
        sh 'cp DEBIAN/changelog.gz usr/share/doc/adriconf/'

        sh 'binarySize=$(du -s usr/ | cut -f1); replaceString="s/__BINARY_SIZE__/"$binarySize"/"; sed -i $replaceString DEBIAN/control'
        sh 'versionStr=$(cat VERSION); sed -i "s/__VERSION__/"${versionStr}"/" DEBIAN/control'

        sh 'fakeroot tar czf data.tar.gz usr/'
        dir('DEBIAN') {
            sh 'fakeroot tar czf ../control.tar.gz control'
        }
        sh 'echo 2.0 > debian-binary'
        sh 'versionStr=$(cat VERSION);fakeroot ar r adriconf-$versionStr.deb debian-binary control.tar.gz data.tar.gz'
    }
}

If you want to take a look on the package generation of adriconf, take a look in commit 150cfc644a9ac2f63ba9a042ef75a72731adfb44.