Intro

In this post I will show you how to use GitLab CI to build and release a Rust binary.

The pipeline will:

  • Run tests
  • Build a statically linked binary for Linux x86_64
  • Build a Docker image
  • Create a GitLab release with the binary and Docker image attached

The code for this post can be found on GitLab.

Config File

A GitLab CI pipeline is defined in a .gitlab-ci.yml file in the root of the repository. The config file is written in YAML and defines the stages, jobs, and scripts that will be executed.

Below is the .gitlab-ci.yml file we will be using. I will break down each section in the following sections.

.gitlab-ci.yml
# Reference: https://docs.gitlab.com/ee/ci/yaml/

stages:
  - prepare
  - test
  - build
  - release

variables:
  # Environment variable for the Cargo home directory
  CARGO_HOME: $CI_PROJECT_DIR/.cargo

  # Use TLS https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#tls-enabled
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_TLS_VERIFY: 1
  DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"

  # Define image name with environment variables
  IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG

cache:
  # Cache Cargo dependencies between jobs for all pipelines
  key: "$CI_JOB_NAME"
  paths:
    - .cargo/
    - target/

cargo-version:
  stage: prepare
  image: rust:latest
  script:
    - rustc --version && cargo --version

cargo-test:
  stage: test
  image: rust:latest
  script:
    - cargo test --verbose

cargo-build:
  stage: build
  image: rust:latest
  script:
    # Install musl target
    - rustup target add x86_64-unknown-linux-musl
    # Build a statically linked binary
    - cargo build --release --target x86_64-unknown-linux-musl
    # Create a releases directory
    - mkdir releases
    # Copy the binary to the releases directory and append the platform to the filename
    - cp target/x86_64-unknown-linux-musl/release/rust-binary-release-gitlab-ci releases/rust-binary-release-gitlab-ci-x86_64-unknown-linux-musl
  artifacts:
    paths:
      - releases/
  only:
    - tags

docker-build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    # Login to the GitLab container registry
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    # Build and tag the Docker image
    - docker build --pull -t $IMAGE_NAME:$CI_COMMIT_TAG -t $IMAGE_NAME:latest .
    # Push the Docker image to the GitLab container registry
    - docker push $IMAGE_NAME:$CI_COMMIT_TAG
    - docker push $IMAGE_NAME:latest
  after_script:
    # Logout from the GitLab container registry
    - docker logout $CI_REGISTRY
  only:
    - tags

gitlab-release:
  stage: release
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  script:
    - echo "Creating GitLab release for $CI_COMMIT_TAG"
  release:
    tag_name: $CI_COMMIT_TAG
    name: "Release $CI_COMMIT_TAG"
    description: "Release $CI_COMMIT_TAG"
    assets:
      links:
        - name: "rust-binary-release-gitlab-ci-x86_64-unknown-linux-musl"
          url: "$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_TAG/raw/releases/rust-binary-release-gitlab-ci-x86_64-unknown-linux-musl?job=cargo-build"
          link_type: package
        - name: "Docker Image"
          url: "$CI_PROJECT_URL/container_registry"
          link_type: image
  only:
    - tags

Stages

The pipeline is divided into 4 stages:

  • prepare - Verify the Rust version
  • test - Run tests
  • build - Build the binary and Docker image
  • release - Create a GitLab release
.gitlab-ci.yml
stages:
  - prepare
  - test
  - build
  - release

Prepare

The prepare stage verifies the Rust version.

.gitlab-ci.yml
cargo-version:
  stage: prepare
  image: rust:latest
  script:
    - rustc --version && cargo --version

Test

The test stage runs tests.

.gitlab-ci.yml
cargo-test:
  stage: test
  image: rust:latest
  script:
    - cargo test --verbose

Build

The build stage builds the binary and Docker image.

Binary Build

The cargo-build job builds a statically linked binary for Linux x86_64.

.gitlab-ci.yml
cargo-build:
  stage: build
  image: rust:latest
  script:
    # Install musl target
    - rustup target add x86_64-unknown-linux-musl
    # Build a statically linked binary
    - cargo build --release --target x86_64-unknown-linux-musl
    # Create a releases directory
    - mkdir releases
    # Copy the binary to the releases directory and append the platform to the filename
    - cp target/x86_64-unknown-linux-musl/release/rust-binary-release-gitlab-ci releases/rust-binary-release-gitlab-ci-x86_64-unknown-linux-musl
  artifacts:
    paths:
      - releases/
  only:
    - tags

The artifacts section of the job saves the binary as an artifact for use in the gitlab-release job later on. The only section ensures the job only runs when a tag is pushed.

Docker Build

The docker-build job builds a Docker image and pushes it to the GitLab container registry.

.gitlab-ci.yml
docker-build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    # Login to the GitLab container registry
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    # Build and tag the Docker image
    - docker build --pull -t $IMAGE_NAME:$CI_COMMIT_TAG -t $IMAGE_NAME:latest .
    # Push the Docker image to the GitLab container registry
    - docker push $IMAGE_NAME:$CI_COMMIT_TAG
    - docker push $IMAGE_NAME:latest
  after_script:
    # Logout from the GitLab container registry
    - docker logout $CI_REGISTRY
  only:
    - tags

The services section of the job runs Docker in Docker (DinD) which is required to build and push Docker images. The before_script section logs into the GitLab container registry. The after_script section logs out of the GitLab container registry. The only section ensures the job only runs when a tag is pushed.

The Dockerfile is shown below:

Dockerfile
FROM rust:latest as builder
WORKDIR /app
COPY . .
RUN rustup target add x86_64-unknown-linux-musl && \
    cargo build --release --target x86_64-unknown-linux-musl

FROM alpine:latest
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-binary-release-gitlab-ci /usr/local/bin/rust-binary-release-gitlab-ci
CMD ["rust-binary-release-gitlab-ci"]

Release

The gitlab-release job creates a GitLab release with the binary and Docker image attached.

.gitlab-ci.yml
gitlab-release:
  stage: release
  image: registry.gitlab.com/gitlab-org/release-cli:latest
  script:
    - echo "Creating GitLab release for $CI_COMMIT_TAG"
  release:
    tag_name: $CI_COMMIT_TAG
    name: "Release $CI_COMMIT_TAG"
    description: "Release $CI_COMMIT_TAG"
    assets:
      links:
        - name: "rust-binary-release-gitlab-ci-x86_64-unknown-linux-musl"
          url: "$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_TAG/raw/releases/rust-binary-release-gitlab-ci-x86_64-unknown-linux-musl?job=cargo-build"
          link_type: package
        - name: "Docker Image"
          url: "$CI_PROJECT_URL/container_registry"
          link_type: image
  only:
    - tags

The release section of the job defines the release metadata. The assets section defines the assets that will be attached to the release. The first asset is the binary built in the cargo-build job. The second asset is a link to the Docker image in the GitLab container registry.

Bonus Round

The pipeline also includes some additional configuration for caching and environment variables.

Caching

The pipeline caches the Cargo dependencies between jobs for all pipelines. This speeds up the pipeline by not having to download the dependencies every time.

.gitlab-ci.yml
cache:
  # Cache Cargo dependencies between jobs for all pipelines
  key: "$CI_JOB_NAME"
  paths:
    - .cargo/
    - target/

Environment Variables

The pipeline defines some environment variables for the Cargo home directory and Docker configuration.

.gitlab-ci.yml
variables:
  # Environment variable for the Cargo home directory
  CARGO_HOME: $CI_PROJECT_DIR/.cargo

  # Use TLS https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#tls-enabled
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_TLS_VERIFY: 1
  DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"

  # Define image name with environment variables
  IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG

Improvements

There are a few improvements that could be made to this pipeline:

  • Build binaries for multiple platforms (macOS, Windows, etc.)
  • Use a matrix to build multiple binaries in parallel
  • Use a more descriptive release description
  • Add a changelog to the release

Outro

In this post I showed you how to use GitLab CI to build and release a Rust binary. The pipeline runs tests, builds a statically linked binary for Linux x86_64, builds a Docker image, and creates a GitLab release with the binary and Docker image attached.

Below is a diagram of the pipeline stages:

For a GitHub Actions version of this pipeline, see my post on Rust Binary and Docker Releases using GitHub Actions.