Christopher Orr

Directly link to report artifacts when using GitLab CI

GitLab has a lot of powerful features, but also comes with a lot of rough edges, with UX issues that can remain open on their public issue tracker for years.

This article describes a workaround I implemented to make code coverage reports accessible with a single click from a GitLab Merge Request (MR) page.

GitLab pipeline result shown on MR page, with 'View app' button

The end result: Clicking ‘View app’ will open our code coverage report

Browsing build artifacts

If you have a build that generates a code coverage (or any other) HTML report, it might typically have an index page, plus other pages for each module or class in your application.

As with other CI systems, GitLab CI lets you attach an arbitrary set of files or directories — known as “artifacts” — to the execution of a build pipeline.

As a former Jenkins user (and continued fan), I’m used to artifacts being very accessible for any given build. However, GitLab requires you to know exactly which job within a pipeline generated the artifacts, and to then go on a journey…

The six-click journey

For example, to find our code coverage report for a particular MR, we need to:

  1. Click on the pipeline ID on the Merge Request page
  2. Click on the specific job within that pipeline, where the artifacts were generated
  3. In the “Job artifacts” panel on the right, click “Browse”

Now to actually open the report, assuming it was archived in a top-level directory called coverage-report, we need a further three clicks:

  1. Click on coverage-report in the artifact browser
  2. Scroll down, and click index.html
  3. Click through the warning from GitLab that you’re about to potentially view malicious code
GitLab interstitial with warning about visiting the URL, as it may be malicious

GitLab default: Six clicks, including a warning, to reach our HTML report

The code coverage report does now indeed get rendered as HTML, and we can click around it as usual. Despite being hosted on GitLab Pages, there is a transparent authentication step that ensures that the report is only accessible to repo users. 👍

A slight improvement: Using “expose_as”

The GitLab CI pipeline definition for the above setup could look something like this, using the artifacts syntax to store the generated directory of HTML report files:

build-and-test:
  stage: build
  script:
    - ./build.sh
    - ./test.sh
    - ./generate-coverage-report.sh

  artifacts:
    paths:
      - coverage-report/

To this, you can add the expose_as keyword, which lets you provide a name to be shown in the “View exposed artifacts” section at the top of your Merge Request page:

  artifacts:
    expose_as: "Code Coverage Report"
    paths:
      - coverage-report/
GitLab pipeline result shown on MR page, with link to exposed artifact

One click saved: first expand the exposed artifacts section, then open the artifact browser

However, as the many caveats in the artifacts:expose_as documentation explain, this just links to the artifact browser, where we once again have to follow those three steps above. So this leaves us with five clicks instead of six.

There is an open GitLab issue which proposes allowing expose_as to handle directories like coverage-report/, and be able to link to index.html without having to go through the artifact browser. Assuming this would be implemented, this would bring us down to three clicks: one to expand the artifacts section, another to open the report, and a third to click through the warning.

A one-click solution

We’ll combine two GitLab CI features to make our code coverage report accessible in a single click from the Merge Request page.

Artifacts published to GitLab Pages

As we can see from the artifact browser, our job artifacts are automatically published to a URL on the GitLab Pages gitlab.io domain. These follow the pattern:

https://<group>.gitlab.io/-/<repo-path>/-/jobs/<job-id>/artifacts/<file-path>

I wasn’t able to find documentation for this behaviour or this URL, but it seems to automatically happen, even when GitLab Pages hasn’t been enabled for the repo.

Our goal is to link to this URL directly from the MR page, bypassing the artifact browser.

Using GitLab Environments

With environments, GitLab CI lets you keep track of what has been deployed to production, staging, or any other environment you might have.

These are configured as part of your pipeline definition, and each environment can have an associated URL. After a CI job completes, this URL will be shown on the MR page, which is exactly what we want!

We can take advantage of “dynamic environments”: creating a new environment automatically, based on any variables available during a CI pipeline.

In our case, we want to create an environment specific to each Merge Request. For example, in addition to the usual staging and production environments, here we have some named like MR/390/code-coverage, with an “Open” button leading to the associated URL:

GitLab CI environments list

The GitLab CI environments list, with a ‘folder’ shown based on common prefixes

Note that these dynamic environments are temporary — nothing is being deployed by creating one; we’re just taking advantage of the fact that they can have a URL associated with them, and are displayed prominently on the MR page.

Bringing these two features together

Here is how we can upgrade our .gitlab-ci.yml snippet to create a dynamic environment, whose URL points directly to the published report on GitLab Pages:

# Build and test our code, generating a code coverage report
build-and-test:
  stage: build
  script:
    - ./build.sh
    - ./test.sh
    - ./generate-coverage-report.sh

  artifacts:
    # Archive the code coverage report, so that it's published to gitlab.io
    paths:
      - coverage-report/

  # Create a dynamic environment for this MR.
  # Use the expected gitlab.io artifact URL as the environment URL.
  # This gives us a "View app" link on the MR page that takes us straight there
  environment:
    name: MR/${CI_MERGE_REQUEST_IID}/code-coverage
    url: https://<group>.gitlab.io/-/<repo-path>/-/jobs/${CI_JOB_ID}/artifacts/coverage-report/index.html

That’s it! 🎉 Once this CI job completes, a link to the newly created environment will appear on the MR page, giving us one-click access to the code coverage report:

GitLab pipeline result shown on MR page, with 'View app' button

Easy: Clicking ‘View app’ will open our code coverage report

Implementation notes

Double-check the *.gitlab.io artifact URL that gets generated for your jobs, and replace the group/repo path values above.

Note that ${CI_MERGE_REQUEST_IID} is only set when a pipeline is running for a Merge Request. So if this job would run for your main branch, you’d end up creating a dynamic environment called MR//code-coverage. That’s not really a problem, but you might want to make sure that this artifact/environment setup only runs in an MR-specific job.

You can split up generation/publishing of the report and creation of the dynamic environment into separate jobs, but note that the ${CI_JOB_ID} in the URL must refer to the job where the artifacts were stored. For example:

# Build and test our code, generating a code coverage report
build-and-test:
  stage: build
  script:
    - ./build.sh
    - ./test.sh
    - ./generate-coverage-report.sh
  after_script:
    # Store the current job ID for use in downstream jobs
    # https://docs.gitlab.com/17.4/ee/ci/variables/index.html#pass-an-environment-variable-to-another-job
    - echo "COVERAGE_REPORT_JOB_ID=${CI_JOB_ID}" > coverage.env
  artifacts:
    # Archive the code coverage report, so that it's published to gitlab.io
    paths:
      - coverage-report/
    reports:
      dotenv:
        - coverage.env

# For MRs only, create an environment to easily access the coverage report.
# This requires knowing the `CI_JOB_ID` where the report was stored, which
# will be automatically injected into this environment from the dotenv file
coverage-report:
  stage: report
  only:
    - merge_requests
  needs:
    - build-and-test
  script:
    - echo "Publishing code coverage report"
  environment:
    name: MR/${CI_MERGE_REQUEST_IID}/code-coverage
    url: https://<group>.gitlab.io/-/<repo-path>/-/jobs/${COVERAGE_REPORT_JOB_ID}/artifacts/coverage-report/index.html

You can also use this approach to publish multiple reports. Create a new job for each report, with its own dynamic environment name. All of these environments will appear together on the MR page, with their respective URLs.

The dynamic environment(s) for an MR should automatically be deleted when it’s closed, so no cleanup is necessary.

Final thought

Hopefully GitLab will improve the workflow for opening artifacts directly from the MR page, perhaps even removing the warning interstitial if linking to gitlab.io artifacts which belong to the same repo.

(I understand that potentially an attacker could open an MR that writes something malicious to the artifacts, but perhaps the warning could be shown only for pipelines triggered by external contributors.)

In any case, I hope this workaround will be useful for your repos! Let me know what you think.