GitLab is an open-source-core collaborative development platform that has project planning and continuous integration features built in. I like it, because it is very easy to use, and yet powerful. The free hosted version gives you a lot of awesome features, but you can even self-host GitLab, as it is open-source and permissively licensed. In this series, I'm going to be showing you a few examples on how you can set it up so that you get the most out of it. Hopefully you will find it useful.
In this article, I will show you how you can use GitLab to host your Rust project, complete with a working CI setup that will run your tests, build the project and publish documentation on every commit. If you like open-source software and don't like complicated CI systems, this article is for you.
You can skip this article and go straight to the repository if you just want to see a minimal example.
Setting up the Project
I'm assuming you already have a GitLab account. If you do not, you should create one, they are free and you will get a lot of free perks with it, such as free access to some amount of CI runner minutes. You will also need to set up your SSH keys so you can push to it.
We will need to create a new project to walk you through how to set up the CI to get the most out of it. When you log in to GitLab, you are typically greeted by your list of projects. You can either press the big blue New Project button or press the little plus in the navigation bar up top and select New project/repository.
Once you have found either one of these buttons, they should take you to the new project page. Here, GitLab will ask you if you want to create a blank project or from a template. You can select Create blank project.
Once you've selected that, it will ask you for a name for the project, and some other options. You can put in any name you like, I'm naming this one Rust GitLab Example. Notice that it will fill in the URL for you, but you can also customize it. To make it easier to type, the URL should use lowercase characters and dashes rather than spaces. I have also deselected the Initialize repository with README option, because I'll write one myself.
When you are happy with your selection, hit the Create project button and it should take you to the new, empty project page.
Now, you've successfully setup a new GitLab project. In the next section, I'll show you how to put some Rust code into it and setup the CI to do it's magic.
Creating a Rust Project
Next up, we need new Rust project that we can push to GitLab. If you're doing this with your own Rust project, you'll obviously already have some code to push. I don't, so I'm going to write some placeholder code. As usual, start out by using Cargo to generate a new project:
$ cargo new rust-gitlab-example
Next, I add clap as a dependency to let me parse command-line arguments. As usual, cargo add
does this quickly.
$ cargo add clap --features derive
Then, I add some placeholder code. As I said, this is just an example to showcase the CI capabilities, feel free to use your own Rust project instead. I drop this into src/main.rs
:
//! # Rust GitLab Example
//!
//! This implements a little command-line utility which will print a greeting for
//! a specified name.
use clap::Parser;
/// Command-line options
#[derive(Parser, Clone, Debug)]
pub struct Options {
/// Name to use for greeting
#[clap(long, short, default_value = "Human")]
name: String,
}
/// Given a name, generate a greeting.
fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[test]
fn greeting_works() {
assert_eq!(greeting("Mark"), "Hello Mark!".to_string());
}
fn main() {
let options = Options::parse();
println!("{}", greeting(&options.name));
}
You can verify that everything works by running the unit tests:
$ cargo test
running 1 test
test greeting_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
We can also make sure that the binary works, by running it with cargo run
.
$ cargo run
Hello, Human!
$ cargo run -- --name Patrick
Hello, Patrick!
At this point, we should have some code that runs. Bonus points for having documentation comments and some unit tests. These are important because in the next section we'll be adding a CI pipeline job that will run the unit tests and publish the documentation.
Writing CI Configuration
Now that we have some example code, we have to tell the GitLab CI system what it should do. We do this by creating a .gitlab-ci.yml
file in the repository with a configuration. This is fortunately not very difficult, but before I explain how this is done, let me quickly walk you through the basic concepts.
The CI system uses the concepts of jobs and stages. Jobs belong to stages. Stages (and with them, all jobs that are part of them) run sequentially, in the order that you define them. All of the jobs that belong to a stage run in parallel, meaning at the same time. A run of all of the jobs and stages is called a pipeline. If any of the jobs in a stage fails, the entire stage is marked as failing and the CI pipeline run is marked as a failure. The entire pipeline is launched whenever you push new code, tags, branches, create merge requests, or launch it manually.
For advanced users, GitLab also offers the ability to declare your CI jobs as a Directed Acyclic Graph to have your jobs run faster, which can be useful when you have a lot of them. You can also mark jobs as being allowed to fail, or only run conditionally (for example only when files in a certain folder have changed, which is useful when you're stuck with a big monorepo). This article barely scratches the surface of what the CI system can do, check out the documentation if you want to know more.
For the jobs, GitLab CI makes use of Docker. Every job launches some docker container (that we can specify) and runs some commands inside of that container. If we create any new files within a job that we want to keep or that we want future jobs to have access to, we can create artifacts, which are files or directories that are created as the output of a job and that future jobs can use as inputs. So to recap:
Name | Description |
---|---|
Job | A few commands that run in a Docker container. Can produce some output in the form of artifacts. If any of the commands do not exit successfully, the job is considered failed. |
Stage | A collection of jobs that run in parallel. If any of the jobs fail, the entire stage is considered failed. |
Pipeline | A collection of stages that run sequentially. If any of the stages fail, the pipeline run is considered failed. Is triggered by pushing commits, tags, branches, creating merge requests or can be triggered manually. |
Defining Stages
For this example, we want to define three stages:
- test, where we run unit tests,
- build, where we compile the code,
- publish, where we publish the binaries and documentation.
You can start out by creating an empty .gitlab-ci.yml
file and adding the list of stages to it:
stages:
- test
- build
- publish
Defining Jobs
Next, we have to define the jobs. We can start by creating the tests job, which will run the unit tests. This job does not produce any output, so we do not declare any artifacts here. We declare that this job is part of the tests stage. We also declare that we want it to use the rust
Docker image. If any of the steps in the script
section fails, the entire job is treated as a failure. The cargo test
command will return a non-success status code on test failure, so that will do what we want. This is what we add to our .gitlab-ci.yml
file:
tests:
stage: test
image: rust
script:
- cargo test
Looks easy, right?
Next, we want to define the jobs where we actually build the binary, and while we're at it we'll also define a job where we build the documentation. These jobs are both part of the build
stage, so that they will run in parallel. One difference here to the tests job is that both of these jobs generate artifacts. The build job will export the generated binary as an artifact, while the documentation job will export the entire documentation folder. You can export multiple files or folders from a single job by adding more paths.
Job artifacts are files or folders that are created by jobs. They are stored and can either be used as dependencies for future jobs, or downloaded using the API.
build:amd64:
stage: build
image: rust
script:
- cargo build --release
artifacts:
paths:
- target/release/rust-gitlab-example
rustdoc:
stage: build
image: rust
script:
- cargo doc
artifacts:
paths:
- target/doc
Finally, we want to publish the built binary and documentation. We can use the GitLab Pages feature to accomplish that. This is a feature of GitLab where they will host static files for you, free of charge. To make use of that, you need to create a CI job called pages
and it must export an artifact folder called public
. Whatever is in that folder, will then be publicly hosted at xfbs.gitlab.io/rust-gitlab-example.
GitLab Pages lets you host static content by declaring a CI job called
pages
. By default, any artifacts in thepublic/
folder from that CI job will be hosted atyourusername.gitlab.io/projectname
. You can add a custom domain to host it at a more friendly domain through the UI. If your project is private, by default this hosted page is protected too, so it is also useful for publishing project-internal or confidential documentation.
Note in this stage that we are using a different Docker container (the alpine container) because we do not need to build any Rust. We also declare some dependencies, this means that CI will fetch the artifacts from those jobs and extract them so that they are accessible in this job. In the job itself, we really just copy things to the right place and don't do anything else.
One important note about this job: this job is only set to run when pushing to the master
branch. This is very important, because the published documentation should reflect what is on the master branch, and if this were to be omitted, this job would also run when creating private branches or merge requests.
pages:
stage: publish
image: alpine
dependencies:
- build:amd64
- rustdoc
script:
- mkdir -p public
- mv target/doc public/doc
- mv target/release/rust-gitlab-example
artifacts:
paths:
- public
only:
- master
You can find the full .gitlab-ci.yml
file here.
Pushing it to GitLab
Now, saving this file and pushing it up to GitLab, we can see that our pipeline is being correctly detected. In the left navigation, there is a little rocket icon labelled CI/CD, clicking this will get you to the list of CI pipeline runs, and clicking on the latest one will look something like this:
You can click on any of the pipeline jobs to see the output of the script that was running. For example, if we want to see if there was any compiler warnings or issues when running the unit tests, we can click on the tests
job.
During the pages
job, we've published the documentation that we generated and we've published the binaries. We can check that this is published:
These things are available here, if you want to check them out yourself:
- Binary: xfbs.gitlab.io/rust-gitlab-example/rust-gitlab-example-amd64
- Documentation: xfbs.gitlab.io/rust-gitlab-example/doc
Conclusion
The GitLab repository that I created in this article is available here.
GitLab makes it very easy to create repositories, use off-the-shelf Docker containers to put together custom CI jobs. We were able to add CI capabilities including publishing binaries and documentation with only a handful of lines of configuration. The fact that it simply uses regular Docker containers also makes it easier to locally debug CI configuration.
GitLab Pages makes hosting static things pleasant and is perfect for things like nightly builds, test coverage reports, or documentation. Some people even use it to host blogs or personal websites. For example, passgen.it, my password generator with a Regex-like syntax, is hosted by GitLab Pages with a custom domain, and the releases (binaries, tarballs, debian packages) are generated by the CI on every commit for all platforms.
Top comments (1)
Thanks for this very clear introduction!
I noticed two typos:
Thanks again,
Cheers,
Clément.