Using GitLab’s CI for Go Packages

Sat Apr 25, 2020
~1700 Words
Tags: programming, Go, CI

Good support for continuous integration (CI) has become table stakes for online code hosting. Continuous testing and monitoring of your code can help raise and maintain code quality. However, even when you have access, configuring CI well takes effort. The goal of this post is to present a starting configuration suitable for Go projects hosted on GitLab.

The configuration below should not necessarily be taken as complete. The project used here as an example is very, very simple, and so many complexities that larger projects face will be avoided, but it hopefully will serve as a good introduction.

For this introduction, I’m assuming a basic understanding that GitLab arranges the CI as a “pipeline”, which is composed of several “jobs”. Those jobs are run stages. Although the names of the stages is configurable, the standard stages are: build, test, and deploy.

Build Stage

Before discussing the jobs in the build stage, my standard CI configuration contains a prefix to create a standard script executed at the start of every job.

1
2
3
before_script: &before_script_all
  - cat /etc/*-release
  - if [ -x "$(command -v go)" ]; then go version; fi;

This fragment creates a default before_script, which will be set on every job in the pipeline. Each job can override this value, but the above is a good default. In this case, the only goal is to write some telemetry to the log. In principle, all of this information is known from the docker images run (see later), but docker images are not immutable, and this information can be useful. Additionally, note the test that you can run a command go. Not every job will run using a docker image with Go installed, and omitting the test will cause those jobs to fail.

Next, we create a job for every version of Go that is supported by the project. This is somewhat complicated because of the introduction of modules. In practice, this means that the jobs take two different forms.

Modules

When using modules, the job definition is very simple:

1
2
3
4
5
6
golang-1.14:
  image: golang:1.14
  stage: build
  script:
    - go build .
    - go test .

The above defines a job call golang-1.14. The docker image is golang:1.14. The jobs will run during the build stage. Finally, the script sets the actions to be run. In the script, you can see that two commands will be executed, and the package will be built and tested. As you may have guessed, this job will build and test the package using version 1.14. With Go modules, building and testing is straightforward.

Similar jobs, except with versions 1.11 through 1.13 can be added.

GOPATH

When using older versions of Go, the package needs to be copied into the correct folder in the GOPATH. There are a couple of ways of arranging this, but the simplest approach is to configure the jobs so that the repository is cloned into the correct folder.

1
2
3
4
5
6
7
8
9
golang-1.10:
  image: golang:1.10
  stage: build
  variables:
    GOPATH: $CI_BUILDS_DIR
    GIT_CLONE_PATH: $CI_BUILDS_DIR/src/gitlab.com/$CI_PROJECT_PATH
  script:
    - go build gitlab.com/$CI_PROJECT_PATH
    - go test gitlab.com/$CI_PROJECT_PATH

The key is to use the variables key to set certain environment variables. These environmental variables can control many aspect of the job, including the script. All of the build actions are supposed to occur in or below the directory specified by $CI_BUILDS_DIR, which is predefined by the GitLab runner. We therefore use this as the GOPATH, and use GIT_CLONE_PATH to set the location for cloning the repository to match Go’s expectations.

Similar jobs, except with versions 1.9 and below can be added.

Possible Improvements

The jobs above are good for leaf packages (i.e. packages that rely only on the standard library). If the package is importing third party packages, then it might be worthwhile to configure caching. An example of caching is shown in a later job. Caching downloads might speed up the builds.

If building the package takes a long time, it might be worthwhile to keep certain files or directories as artifacts. Artifacts are kept after the job has been completed, and can be copied into later jobs. Again, and example of using artifacts is shown in a later job.

Test Stage

Because the testing was very quick, the package has already been tested many times by the jobs in the build stage. Still, there is additional testing to be done, and that testing will be done in the test stage. There are 5 jobs.

test Job

This job is used to run all of the code quality tests that are required, and which will cause the pipeline to fail. In other words, this job is for those tests that should block acceptance of any commits.

 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
test:
  image: golang:1.12
  stage: test
  variables:
    GOPATH: $CI_BUILDS_DIR
    GIT_CLONE_PATH: $CI_BUILDS_DIR/src/gitlab.com/$CI_PROJECT_PATH
  before_script:
    - *before_script_all
    - mkdir -p .cache/github.com
    - mv .cache/github.com $GOPATH/src/
  after_script:
    - mv $GOPATH/src/github.com .cache/
  script:
    - go test -coverprofile=cover.out -v .
    - go tool cover -html=cover.out -o=cover.html
    - go vet .
    - go get -u github.com/gordonklaus/ineffassign
    - go install github.com/gordonklaus/ineffassign
    - $GOPATH/bin/ineffassign .
    - go get -u github.com/client9/misspell/cmd/misspell
    - go install github.com/client9/misspell/cmd/misspell
    - $GOPATH/bin/misspell -locale UK *.go *.md
    - go get -u github.com/securego/gosec/cmd/gosec
    - go install github.com/securego/gosec/cmd/gosec
    - $GOPATH/bin/gosec ./...
  cache:
    key: test
    paths:
      - .cache/github.com/
  except:
    - tags
  artifacts:
    paths:
      - cover.html

Because we are downloading third-party packages, this job is configured to use a cache. There are limitations on the location of cached files, so the before_script is adjusted to move cached files to the correct location in the GOPATH, and adding an after_script. Note the use of YAML anchors to reuse the existing before_script.

Again, the package is tested using go test. However, this time we capture a cover profile, and generate an HTML report. That HTML report is listed as an artifact. The pipeline will use it in a later job.

In this particular case, you can see that the job also runs various code analysis tools. In particular, go vet, ineffassign, misspell, and gosec. These are chosen mostly because they are the metrics used by Go Report Card. The exact list will obviously change between projects depending on their policy, but failure of any of these tools will cause the job to fail, and so cause the pipeline to fail.

Improvements

All of the tools used above are included in GolangCI-Lint, so the above job could be simplified by simply installing that tool, and using it to run the checks. A further improvement would be to put that tool in a cache shared with the next job.

lint Job

Often, there are additional code quality metrics that a project wants to track. Unlike the previous job, failing to meet these criteria are not supposed to cause the pipeline to fail. We therefore configure a job where… failure is allowed. Note the last key in the job definition.

1
2
3
4
5
6
7
8
9
lint:
  image: golang:1.14
  stage: test
  script:
    - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- v1.23.6
    - ./bin/golangci-lint run -v --enable-all .
  except:
    - tags
  allow_failure: true

In this particular case, GolangCI-Lint is installed, and the job runs all the linters via --enable-all. Reviewing the build log will show any possible issued detected by the large list of linters enabled, but findings will not cause the pipeline to fail.

Take care when enabling all checks from GolangCI-Lint. The list of checks can change, so either makesure that the version is pinned, or that the tool is only allowed to run in a job that can fail, such as our dedicated lint job.

Improvements

Most obviously, you should consider adding a configuration file for the linter into your repository to adjust the linters used.

The download of the linter on every run is perhaps wasteful, and could be improved by caching the executable. However, care would be need to invalidate the cache when adjusting the version.

Gitlab Supplied Jobs

Finally in the test stage, we configure a number of jobs provided by GitLab:

1
2
3
4
include:
  - template: SAST.gitlab-ci.yml
  - template: License-Scanning.gitlab-ci.yml
  - template: Dependency-Scanning.gitlab-ci.yml

From the key name, include, it should be clear that these lines pull in definitions from other YAML files. In particular, the three files configure 3 additional jobs.

The SAST jobs is a security scanner. For Go, the security scanner is gosec. This is one of the tools that we included in the test job. Including it again serves two purposes. First, this job will populate the Security Dashboard for the project. Additionally, some projects will want to disable some rules used by gosec. That is acceptable, but this job will keep a record of all issues detected.

The two remaining jobs depend on detecting that Go modules are being used. They will not work if your project only works with GOPATH. Be sure to include both go.mod and go.sum in your repository. These jobs will also populate dashboards for the project, and review the project’s dependencies for conflicting licenses or security issues. For a simple project without any dependencies, these jobs admittedly have very little to do, but are present if the project grows.

Deploy Stage

For a package, you might think there is nothing to deploy. Certainly, there is no application or service to deploy. However, we do have our HTML report on code coverage, which should be published.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pages:
  stage: deploy
  dependencies:
    - test
  script:
    - mkdir ./public
    - mv cover.html ./public
  only:
    - master
  artifacts:
    paths:
      - public

For GitLab, a job called pages is treated specially. Artifacts from this jobs will be used to create a static website. By specifying a dependency on the test job, the artifacts from that job will be present for the pages job. Remember that we only had one artifact, which was the HTML report. Note that the script can access and copy cover.html that was generated in the earlier job.

Improvements

For an application, the deploy stage can also include a job to build all of the executables. Especially with Go, the job can cross-compile executables for all of the desired platforms.

Summary

Even for a very simple project, a complete CI configuration for GitLab is 13 jobs. That number can certainly be lowered by testing against fewer versions of Go, but it can also easily increase.

Places to join the discussion