First glance at GitLab CI

CIDocker

It's nice! I have set up the pipeline for this site.

I have prior experience with GitHub Actions, and I have never known how they actually work under the hood and what those magic "actions" do. What I like about GitLab CI the most, is that it simply works with Docker containers.

Build and deploy

This site is a VitePress-based project and is built with Node.js. Initially, the CI consisted only from build job:

build:
  image: node:20-alpine
  script:
    - corepack enable
    - pnpm i
    - pnpm vitepress build
  artifacts:
    paths:
      - .vitepress/dist

That's all. In order to deploy it on GitLab Pages, I also added pages job:

stages:
  - build
  - deploy

build:
  stage: build
  image: node:20-alpine
  script:
    - corepack enable
    - pnpm i
    - pnpm vitepress build
  artifacts:
    paths:
      - .vitepress/dist

pages:
  stage: deploy
  script:
    - mv .vitepress/dist/ public/
  artifacts:
    paths:
      - 'public/'

Thank you for reading this article, bye~ Let's overcomplicate things?

Set up testing

Excuse me, why do you ever need to set up testing for a personal blog project?

Reasonable question. Well, from the beginning I wanted to add some scripting feature to the site, and it had some algorithmic logic. I wrote implementation and tested it in browser, and it was buggy. I don't like to debug things manually. Instead, I write unit tests and debug such functionality with instant feedback loop (thanks to Vitest).

So, yeah, I have added Vitest to the project, and wanted to integrate it into CI. The first, naive way to do so was to deplicate build job, but with running pnpm test:

stages:
  - test
  - build
  - deploy

default:
  image: node:20-alpine

test:
  stage: test
  script:
    - corepack enable
    - pnpm i
    - pnpm test

build:
  stage: build
  script:
    - corepack enable
    - pnpm i
    - pnpm build
  artifacts:
    paths:
      - .vitepress/dist

pages:
  stage: deploy
  script:
    - mv .vitepress/dist/ public/
  artifacts:
    paths:
      - 'public/'

It worked, but the whole pipeline took 1m 7s, where

  • test job took 22s
  • build job took 27s
  • deploy job took 17s (!)

I was concerned about the duration and tried to optimise the pipeline.

Optimising

Without looking to the internal timing of jobs (which was a mistake), I thought that the main strategy is to avoid to doing installation several times. Installation consists of calling corepack enable (to install a proper version of pnpm), and pnpm install (to install project dependencies). The artifacts of these operations should be available between jobs. There are two tools to achieve it: artifacts and caching. I don't like the first way, because if I specify e.g. node_modules as an artifact, GitLab will save it as a pipeline artifact. I don't want to store garbage, so I decided to proceed with caching.

I don't know how to cache result of corepack enable. This command downloads pnpm and sets up pnpm binary in the environment. Where it stores downloaded pnpm and which file it patches to achieve pnpm availability - IDK. I researched a bit, didn't find anything useful and decided not caching the Corepack step.

However, caching of pnpm artifacts is more CI-friendly. Its artifacts are:

  • Store, i.e. .pnpm-store. It is a global (in scope of OS) store of downloaded packages that could be shared between multiple projects.
  • node_modules. It is a directory of a project's installed dependencies, and pnpm links each package to .pnpm-store.

Initially I came up with a setup stage that prepares node_modules and shares it between jobs:

stages:
  - setup
  - test
  - build
  - deploy

default:
  image: node:20-alpine

Setup:
  stage: setup
  script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    - pnpm install
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - node_modules

test:
  stage: test
  script:
    - corepack enable # it is necessary to set up `pnpm` again
    - pnpm test
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - node_modules
    policy: pull

build:
  stage: build
  script:
    - corepack enable
    - pnpm build
  artifacts:
    paths:
      - .vitepress/dist
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - node_modules
    policy: pull

pages:
  stage: deploy
  script:
    - mv .vitepress/dist/ public/
  artifacts:
    paths:
      - 'public/'

It was running for... 1m 22s! (previous pipeline took 1m 7s).

  • setup job took 22s
  • test job took 18s
  • build job took 24s
  • deploy job took 17s

This doesn't look like an optimisation at all.

I also noticed that most of the time of each job is not the actual execution of my scripts, but containers setup. For example, if you take a look into e.g. the logs for test job, you will see that running corepack enable and pnpm test took only 3s!

After that, I decided to reduce amount of jobs, and also to cache .pnpm-store instead of node_modules as a more general solution.

If we are in a monorepo setup, where each package has its own node_modules, it makes more sense to cache .pnpm-store as a single entrypoint.

stages:
  - test
  - build
  - deploy

default:
  image: node:20-alpine

cache:
  key:
    files:
      - pnpm-lock.yaml
  paths:
    - .pnpm-store

before_script:
  - corepack enable
  - pnpm config set store-dir .pnpm-store
  - pnpm i

test:
  stage: test
  script:
    - pnpm test

build:
  stage: build
  script:
    - pnpm build
  artifacts:
    paths:
      - .vitepress/dist

pages:
  stage: deploy
  script:
    - mv .vitepress/dist/ public/
  artifacts:
    paths:
      - 'public/'

What's noticable:

  • Single cache for all jobs
  • Single setup (before_script) for all jobs
  • Each job looks less verbose

Pipeline execution time was 1m 11s, which is... still not fast.

Combine jobs + collect report

As a nice minor improvement, I wanted to leverage GitLab's unit tests reports feature. Also, the idea of combining test and build stages into a single job seemed a good optimisation strategy, since the major part of each job is its container setup, therefore, reducing the amount of jobs saves time.

After setting up test:ci script:

{
  "scripts": {
    "test:ci": "vitest run --reporter junit > vitest.xml"
  }
}

I came up with this CI:

stages:
  - build
  - deploy

default:
  image: node:20-alpine

cache:
  key:
    files:
      - pnpm-lock.yaml
  paths:
    - .pnpm-store

build:
  stage: build
  script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    - pnpm i
    - pnpm test:ci
    - pnpm build
  artifacts:
    paths:
      - vitest.xml
      - .vitepress/dist
    reports:
      junit: vitest.xml

pages:
  stage: deploy
  script:
    - mv .vitepress/dist/ public/
  artifacts:
    paths:
      - public/

This pipeline took 47s.

Finally

I have gone from a simple build-deploy pipeline to a (test and build)-deploy pipeline with caching of installed packages between pipelines, which is quite nice. I like GitLab CI and would like to solve more challenging problems with it.

Although same pipeline would work maybe 2-times faster with GitHub Actions, it isn't a big deal for a hobby project. After all, GitLab CI is more understandable, and I got nice test reports. although idk how much it is useful for me now

Thanks for reading my first post, btw!