Debian packaging with dgit and CI

Some notes on Debian packaging for WMF with dgit. This is solving a slightly different problem than "Debian packaging for upload to Debian", since we are interested in i) source code in git and ii) binary packages for installation, and do not care about source packages at all - Our preferred form for modification is a git repository instead.
You don't need to write your own CI file for this; you can just tell gitlab to use builddebs.yml@repos/sre/wmf-debci as CI/CD configuration directly.
This document presumes an existing package; if you are starting to package software from scratch, there is a separate tutorial that describes how to do that.
== Executive Summary / TL;DR ==
Suppose you want to update the package foo from the Debian bookworm release and build packages for bullseye for WMF. The process looks like this: First, get the source code, and make your changes:
 dgit clone foo bookworm
 cd foo
 git checkout -b bookworm-wikimedia
 #make the changes you want, git commit
 gbp dch --since=dgit/bookworm --ignore-branch -R --distribution=bullseye-wikimedia --commit -N NEWVERSION
Then create a suitably-named empty repository on gitlab, and in that new repository, under Settings-CI/CD-General pipelines, set the CI/CD Configuration file to builddebs.yml@repos/sre/wmf-debci. Add the new repository as a remote and push your new branch:
 git remote add gitlab git@gitlab.wikimedia.org:/repos/YOURREPONAME
 git push -u gitlab bookworm-wikimedia
You should now have a CI pipeline that will build your new package, and make the resulting artifacts available for you to inspect and download.
If you want to also have the CI track updates to the Debian suite your package is based on, then you should also push the dgit/bookworm branch (i.e. git push -u gitlab dgit/bookworm), make a project access token and store it in a masked CI variable called DGIT_CI_TOKEN. Then you can schedule a pipeline run against your dgit/bookworm branch and it will automatically check for updates of your package in Debian, and attempt to merge your changes and build new packages as appropriate.
== Rationale / Further Details ==
At WMF, we store our source code in git, and then install binary packages on our systems (via the APT_repository); so we don't need source packages. This means we can avoid a lot of unnecessary complexity - e.g. quilt which essentially embeds an ersatz revision control system into a source package. We can instead use dgit to fetch us the source code for a package and present it to us in a consistent way where the code we see in our git checkout is exactly the code that we are going to build.
It's important to use dgit clone to fetch the package source code, rather than e.g. looking up the Debian maintainer's source repository and cloning that instead. This is because there are a wide variety of ways that Debian maintainers use to present changes to upstream source code, and sometimes this means that building the tree you clone will result in building unpatched code - you could end up building code without security fixes applied. Using dgit clone means you always end up with a working directory that matches the code you'll actually build, and you can then just treat it like a regular git checkout.
=== Fetching Source Code ===
This is as simple as dgit clone packagename suite. Here suite is the codename of the Debian release you want, e.g. bookworm. 
If you don't specify a suite, you'll get unstable. If you want a package from ubuntu, add -d ubuntu to the command-line and specify the Ubuntu release name as the suite (e.g. jammy). If you're fetching a Debian package from the current or previous stable releases, it's worth adding ,-security to the suite (e.g. bookworm,-security), as that means you'll get any security updates that have been made available for that release.
=== Branch Naming ===
You should do all your work on a suitably-named branch off the dgit/suite branch that dgit clone created. Your branch should be called suite-wikimedia (or suite-wikimedia-foo if you might want more than one branch tracking the same Debian release), where suite is the Debian suite you want to track not the suite you want to build for.
If you just want the CI to build your package, then you need only push the suite-wikimedia branch; if you want the CI to also attempt to automatically merge in future changes from Debian, then you need to push the dgit/suite branch for the Debian suite(s) you want to track.
=== Making Changes ===
Treat this like a regular git checkout - make changes, git add, git commit as usual. There's no need to worry about quilt or dpkg-source --commit. Leave the contents of debian/patches alone.
If you want to apply an upstream fix (i.e. effectively to backport it), then you can add upstream's remote and cherry-pick if you like (or apply a patch in the usual manner); it's useful to say git cherry-pick -x in this case, as that will show where the patch came from for future reference.
Running a build attempt can modify your working tree, so always make sure you've committed your changes before attempting a build (which means you can use git clean -xdf and git reset --hard to restore your working tree afterwards). But you can just get the CI system to do the building for you :)
=== Making a Changelog Entry ===
==== Choosing a Version Number, backporting ====
The version number in debian/changelog determines the version number of the binaries you build. The version number wants to be higher than the version Debian ships in the distribution you want to install on, but lower than the version in the next distribution. For example, swift in Debian bookworm is version 2.30.0-4, and in Debian trixie is version 2.31.1-3. So if we're building a local package of swift for bookworm-wikimedia, then it wants to be a version higher than 2.30.0-4 and lower than 2.31.1-3. Achieve that by appending +wmf1 to the version number if building a package from the same release and appending ~wmf1 to the version if building a package from the next release. Then increment that integer when you make the next WMF-specific build.
Continuing our example, if we built a swift package for bookworm-wikimedia based on Debian's package from bookworm, we'd make version 2.30.0-4+wmf1, and if building a package based on Debian's version in trixie, we'd make version 2.31.1-3~wmf1. You can use dpkg --compare-versions to check that your version number does what you want, e.g.
 matthew@tsk:~$ dpkg --compare-versions 2.30.0-4+wmf1 gt 2.30.0-4 ; echo $?
 0
Here we've checked that our proposed version number 2.30.0-4+wmf1 is higher than the distribution version 2.30.0-4.
If you want to backport the same version to multiple distributions, it's usual to embed the Debian release number (e.g. 12 for bookworm) into the version number to disambiguate them - so we'd have 2.31.1-3~wmf12+1 for bookworm and 2.31.1-3~wmf11+1 bullseye. As an example, you can check that changelog showing a published version and its backport annotation on the relevant branch.
==== Updating the Changelog ====
If you've made commits with helpful commit messages, then you can use gbp dch to create a changelog entry for you:
 gbp dch --since=dgit/bookworm --ignore-branch -R --distribution=bookworm-wikimedia --commit -N NEWVERSION
Replacing NEWVERSION with the correct version number determined as above. The arguments to this command work as follows: --since tells gbp dch which commits to include when making the changelog entry (here we specify the branch tip corresponding to the version we cloned from Debian); </code>--distribution</code> states which distribution to put in the changelog entry (this must be the WMF suite you're building for - the CI parses the changelog to determine which image to use for the build); -N specifies the version number (it will guess wrongly otherwise); --ignore-branch -R --commit tell gbp dch to ignore the branch layout, make a release entry (rather than an unreleased snapshot), and commit the resulting changelog. By default gbp dch will spawn an editor window, so you can tweak the new changelog entry before it gets committed.
If you just want a new blank changelog entry to edit, then you can instead use
 dch -e -D bookworm-wikimedia -bv NEWVERSION
Which will set up a new changelog entry for you (but with no changes noted therein) to edit and then commit.
Alternatively, you can just edit (and then commit) debian/changelog by hand to make a suitable entry - the elpa-dpkg-dev-el package contains a helpful Emacs mode for editing Debian changelogs. If doing this, be careful to get the format correct as the requirements are stricter than for changelogs in general.
=== Create Repository and set up CI for package builds ===
Make a new repository on WMF gitlab; it needs to be under repos/ in order to have access to CI runners. There will in due course be Policy on where, but for now go with something sensible (e.g. your team may already have a namespace set up). Make an empty repository, and gitlab will tell you the remote to use, which you can add to your checkout thus:
 git remote add gitlab git@gitlab.wikimedia.org:PATH/TO/REPO
Here the new remote is called gitlab, but you can name it whatever you like (I prefer to keep origin for upstream's code); dgit will have created a remote called dgit which you can use to fetch other versions from Debian, and maybe vcs-git for the maintainer's repository (treat with caution, for the same reasons you should use dgit clone rather than closing the maintainer's repo directly).
Before you first push, set up CI (otherwise, you'll need to push a new commit to trigger any CI to run). Do this by hovering over "Settings" and clicking "CI/CD" from the revealed menu, then clicking "Expand" next to "General pipelines". Type builddebs.yml@repos/sre/wmf-debci into the "CI/CD configuration file" box, then click "Save changes". What this does is it uses builddebs.yml from the repos/sre/wmf-debci repo. That sets up jobs that build the tip of branches named suite-wikimedia or suite-wikimedia-*, using the image from the WMF repo that corresponds to the distribution specified in the most recent debian/changelog entry (with -wikimedia stripped off - so specify bookworm-wikimedia in the changelog if you want to build on bookworm.
=== Set up CI to track updates to Debian ===
Debian sometimes updates packages in its stable suites, typically security fixes or minimally-invasive fixes. You might well want any packages you're deploying to production to contain those fixes :-) With dgit this is reasonably easy - you can just do dgit pull on the relevant dgit/* branch and then merge those changes into your packaging branch like any other branch merge operation. The wrinkle is typically debian/changelog which of necessity both Debian and WMF will have updated. There's a special program in the dpkg-dev package called dpkg-mergechangelogs that is designed to help with this - it understands the format (and Debian version numbering) so can typically merge debian/changelog for you assuming you picked good version numbers. dgit code typically sets this up for you, but you can check by running git config --get merge.dpkg-mergechangelogs.name (if that returns nothing, you don't have the merge driver installed). You can run dgit setup-mergechangelogs to set this up in a repository, or refer to dpkg-mergechangelogs(1) if you want to do it yourself.
Obviously, it would be better to do this automatically! builddebs.yml can do so for you, with a little bit of initial setup. Firstly, make sure you push your dgit/* branch to gitlab; in our example above where we cloned a package from bookworm, this would be:
 git push -u gitlab dgit/bookworm
There is CI that will update that branch when the package is updated in Debian (and push the result), then attempt to merge the changes into any packaging branches that track that suite (e.g. in this case branches called bookworm-wikimedia or bookworm-wikimedia-*), make a new changelog entry, and commit and push the result. That then triggers the usual build process for the packaging branch, resulting in new binaries.
The access token that CI runs with by default (CI_JOB_TOKEN) is read-only, though (there is upstream discussion about making it possible to change that), so you need to create a project access token before these CI jobs can run. To do this, hover over "Settings", and then click on "Access Tokens" in the menu that appears. Give the token a sensible name (e.g. "dgit CI token") and expiry date (the maximum is 12 months), give it the "Maintainer" role, select the "write repository" scope, and click "Create project access token". Take a note of the generated token (gitlab won't show it to you again), then select "CI/CD" from the Settings menu and click "Expand" next to "Variables". Click "Add variable", and a popup appears. In Key, put "DGIT_CI_TOKEN" (this is the value the CI looks for to see if it should try and run the update jobs), paste the token into "Value", deselect the "Protect variable" and "Expand variable reference" flags, select the "Mask variable" flag (you don't want the token value appearing in CI output), and click "Add Variable".
At this point, you can run this CI by hand (click on CI/CD in the side menu, click "Run pipeline" on the top right, select one of your dgit branches, and click "Run pipeline"), and if you update a dgit/* branch yourself (e.g. via dgit pull and git push) then the CI will automatically attempt to merge that into any tracking branches.
But we can automate checking Debian for updates too :) Do this by scheduling a pipeline run: click on "CI/CD" in the left menu, and then select "Schedules" from the expanded left menu. Click "New schedule", give the schedule a sensible name and interval pattern (daily ought to be sufficient) & timezone, and then select a dgit/* branch under "Target branch or tag", and click "Save pipeline schedule". If you have multiple dgit branches you want to track, you need only set up one schedule - the relevant CI job checks each extant dgit/* branch in turn.
=== Push your code! ===
Then push your branch - the first branch you push to gitlab becomes the default branch:
 git push -u gitlab bookworm-wikimedia
The CI you set up in the previous step will now attempt to build your package for you, and if it succeeds, you'll have gitlab artifacts containing the output of your build. If you pushed before setting up CI (or are coming to CI setup later on), make another commit on the bookworm-wikimedia branch to kick off a pipeline branch - an edit to debian/changelog for example.
=== Setting Build Options ===
Many Debian packages support the DEB_BUILD_OPTIONS environment variable; you can set this like any other CI variable. For example, setting it to nocheck will skip the build-time test suite (which might be useful if it fails in our CI environment due to e.g. needing IPv6 networking available).
If you require any packages from the relevant -backports suite to build your package, then set the CI variable USEBACKPORTS to any non-zero value (something like "yes" or "True" is probably most clear); note that this means that all of the Build-Dependencies will be taken from -backports where available, not simply those necessary to match a versioned dependency.
If you require additional components from the WMF apt repository for the build, then set them (as a space-separated list) in the EXTRACOMPONENTS CI variable.
== What the CI does ==
The CI is defined in builddebs.yml and should be reasonably well commented.
=== Updates from Debian ===
The dgit_pull job updates dgit/* branches if the corresponding suite in Debian has been updated, and then the dgit_merge job attempts to merge those changes into appropriate tracking branches. Both jobs set GIT_STRATEGY: clone to ensure a properly clean repo, and both require DGIT_CI_TOKEN to be defined (this should be a project access token so the CI jobs can push changes).
The dgit_pull job additionally does not run on push events (since it would be confusing to have updated the dgit branch and pushed it yourself only to have the CI then try and updated the dgit branch further from Debian). It performs a bunch of setup (making sure the dpkg-mergechangelogs merge driver is available, arranging to have sensible commit name & email, and to be able to push), and then runs roughly the following
for branch in $(git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin); do
 if [[ "$branch" =~ dgit/.+ ]] ; then
 git checkout "$branch" ;
 oldsha=$(git show -s --format=%H) ;
 dgit pull ;
 newsha=$(git show -s --format=%H) ;
 if [ "$newsha" != "$oldsha" ] ; then git push origin "$branch" ; fi ;
 fi ;
done
This checkouts out every branch named dgit/* and checks to see if dgit pull makes any changes. If so, it pushes those changes.
The dgit_merge job runs whenever a dgit/* branch is updated. Eliding the uninteresting setup, it runs roughly thus:
git checkout "$CI_COMMIT_BRANCH"
'SUITE=${CI_COMMIT_BRANCH#dgit/}'
for branch in $(git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin); do
 if [[ "$branch" =~ ${SUITE}-wikimedia.* ]] ; then
 git checkout "$branch" ;
 oldsha=$(git show -s --format=%H) ;
 distro=$(dpkg-parsechangelog -S distribution) ;
 vsuffix=$(dpkg-parsechangelog -S version | sed -re 's/^.*([~+]+wmf.*$)/\1/') ;
 git merge --no-edit -m "Automatic CI update of $CI_COMMIT_BRANCH" "$CI_COMMIT_BRANCH" ;
 newsha=$(git show -s --format=%H) ;
 if [ "$newsha" != "$oldsha" ]; then
 debver=$(dpkg-parsechangelog -S version) ;
 version="${debver}${vsuffix}" ;
 dch -b -p --force-distribution -D "$distro" -v "$version" "Automatic CI update from tracking branch $CI_COMMIT_BRANCH" ;
 git add debian/changelog ;
 git commit -m "Auto-generated changelog for $version" ;
 git push origin "$branch" ;
 fi
 fi
done
This checks for branches called suite-wikimedia.* where suite is the Debian suite being tracked. For each of those branches, it extracts the distribution and version from the existing changelog entry (and extracts the local version suffix); attempts to automatically merge in the changes from the dgit branch; if that works (and so the tip of the working branch has changed) then it constructs a suitable new version number by adding the previous local version suffix to the new version number from Debian and makes a new entry in debian/changelog; and finally commits the changelog entry and pushes the updated branch.
Those commits then fire the build jobs for the updated branches (in the same way as you pushing changes to them would).
=== Package Building ===
There are two jobs that run for package builds: pickimage and build_ci_deb.
The pickimage job extracts the suite name from debian/changelog, removes the -wikimedia suffix and stores the result in the SUITE environment variable. This is needed by the build_ci_deb job to know which image to pull from the WMF Docker registry to run the build in.
The build_ci_deb job is very simple - it extends the build_ci_deb job from includes.yml to build on branches named .*-wikimedia.* (except where the commit is a tag matching WMFDEBCI.*: 
build_ci_deb:
 rules:
 - if: $CI_COMMIT_TAG =~ /^WMFDEBCI.*/
 when: never
 - if: $CI_COMMIT_BRANCH =~ /.*-wikimedia.*$/
The actual work is done in the extended build_ci_deb job from includes.yml (doing it this way means that it can be extended by other users more easily). That is:
build_ci_deb:
 stage: build
 rules: *never
 image: docker-registry.wikimedia.org/wmf-debci-${SUITE}
 script:
 # If USEBACKPORTS is set, tell apt to use packages from backports
 - >
 if [ "$USEBACKPORTS" ]; then
 echo -e "Package: *\nPin: release a=${SUITE}-backports\nPin-Priority: 500" >/etc/apt/preferences.d/${SUITE}-backports.pref ;
 fi
 - apt update
 # Install the build-dependencies specified by this package
 - mk-build-deps -i debian/control -t "apt-get -o Debug::pkgProblemResolver=yes -y --no-install-recommends"
 # Build binary package(s) from the current source tree
 # set DEB_BUILD_OPTIONS in ci if you want to e.g. skip tests
 - dpkg-buildpackage -uc -b
 # Make a new directory for build artifacts
 - mkdir WMF_BUILD_DIR
 # dcmd operates on the changes file and everything named therein
 - dcmd cp ../*.changes WMF_BUILD_DIR/
 artifacts:
 # Tell gitlab where to find the build artifacts
 paths:
 - WMF_BUILD_DIR/
 variables:
 GIT_STRATEGY: clone
This requires the SUITE variable to be set (e.g. by the pickimage job). It installs the necessary dependencies, runs a build attempt, and tells gitlab where to find the resulting build artifacts. It specifies GIT_STRATEGY: clone to ensure a clean build environment (otherwise e.g. rebuilding the same package against two target suites may fail).
=== Tagging ===
Iff DGIT_CI_TOKEN is set, then after a successful package build, a tag will be created corresponding to the built version (mangled per DEP14 to make a legal git tag name) and that tag will be pushed to the repository. This step is skipped if the relevant tag already exists. This is done by the imaginatively-named tag_build job. The core of its work is (boilerplate removed for clarity):
tag_build:
 stage: build
 rules:
 - if: $CI_COMMIT_TAG =~ /^WMFDEBCI.*/
 when: never
 - if: $CI_COMMIT_BRANCH =~ /.*-wikimedia.*$/ && $DGIT_CI_TOKEN != null
 needs: ['build_ci_deb']
 script:
 - apt-get -y install ca-certificates dpkg-dev git
 - git fetch -t
 - pversion=$(dpkg-parsechangelog -S version)
 # Version transform per DEP14, trim final newline
 - gversion=$(echo "$pversion" | perl -pe 'y/:~/%_/; s/\.(?=\.|$|lock$)/.#/g; s/\n$//;')
 - >
 if [ -z $(git tag -l "WMFDEBCI/$gversion") ]; then
 git tag -a -m "Automatic CI build of version $pversion" "WMFDEBCI/$gversion" ;
 git push origin "WMFDEBCI/$gversion" ;
 fi
 variables:
 GIT_STRATEGY: clone
The rules and needs declarations ensure that this job only runs after a successful package build (and not if the triggering commit is adding a tag); then a tag is generated and pushed only if necessary (i.e. the relevant tag does not yet exist).
== Further Reading ==
Dgit is well-supplied with documentation; the above process is modified from that in dgit-user(7). The dgit(1) manual has links to all the available manuals; if you are starting a new package from scratch (rather than an existing Debian or Ubuntu package), you may find dgit-maint-merge(7) helpful, although that is still a bit more complex than we need (since we don't need to care about source pacakges). Watch this space for more documentation...