Meet cici-tools, a multi-tool for building GitLab CI/CD pipelines

Brett Weir Jul 3, 2023 9 min read

I've been working on a new project called cici-tools (pronounced "see-see"). It provides a set of command line tools for working with GitLab CI/CD files, where each tool does something useful in its own right. The direction of the project has changed quite a bit in trying to understand what is most needed and what can be reasonably built, but it's gotten to a good enough place to start talking about it.

This project is still experimental and the documentation is a work in progress. I can't promise that it works very well at the moment and would forgive you for not wanting to try it, but for the enterprising among you, I would love your feedback and to know if you found it useful.

Installation

cici-tools is available on PyPI, so you can install it with pip:

python3 -m pip install cici-tools

This will install the cici command into your local environment, which you can validate like so:

cici --version
$ cici --version
cici 0.2.5

Format CI files with cici fmt

The cici fmt tool mostly happened by accident while developing the cici tool. While it hasn't always been clear what I am building, it was always clear that it would modify GitLab CI files in some way.

In my early efforts, I wrote the tool to make as few changes as possible. Then I thought to create a new CI format that compiles back to GitLab CI. In the most recent iteration, cici now implements GitLab CI's schema directly in Python.

This latest approach has been the most time-consuming so far, but it has meant that reading a file in and writing it back out corrects the formatting in the process. Hence, cici fmt was born.

cici fmt can be run with or without files as parameters, like so:

cici fmt
$ cici fmt
.gitlab-ci.yml formatted

If no file is passed, it defaults to a file in the current directory named .gitlab-ci.yml.

When cici fmt is run, it will:

  • Add quotes to strings where the syntax would be ambiguous otherwise,

  • Reorder jobs in the file,

  • Fix indents and line spacing,

  • And some other random stuff.

Currently, it will also expand YAML anchors and extends keywords, because it shares a certain code path with cici bundle, even though it shouldn't. So it's not quite ready for prime time, but I'm working on it.

Once it's finished, it'll be pretty exciting to have a GitLab CI linter that doesn't require calling out to a GitLab instance.

Pin include versions with cici update

There's a question I've pondered for a long time. How does one create shared pipelines, push updates to everyone quickly, and also track changes over time?

There are two obvious choices, with their own obvious problems:

  • If everyone uses main / latest / what have you, everyone picks up changes immediately, and no one has any idea what versions are in use.

  • If everyone pins to a specific version, they know exactly what versions are in use and will likely never upgrade them unless they absolutely have to.

cici update offers a third choice: developers can continuously track the latest pipeline changes using a version-pinning tool so that they always know what versions they have, but are also able to pick up updates automatically.

Here's an example CI file:

# .gitlab-ci.yml
include:
  - project: brettops/pipelines/prettier
    file: include.yml
  - project: brettops/pipelines/python
    file:
      - lint.yml
      - setuptools.yml
      - twine.yml

Now call cici update:

cici update
$ cici update
brettops/pipelines/prettier pinned to 0.1.0
brettops/pipelines/python is the latest at 0.5.0

If you check back into that CI file, you'll see pinned versions:

# .gitlab-ci.yml
include:
  - project: brettops/pipelines/prettier
    ref: 0.1.0
    file: include.yml
  - project: brettops/pipelines/python
    ref: 0.5.0
    file:
      - lint.yml
      - setuptools.yml
      - twine.yml

That's it! That's all it does, but it helps me a lot.

Add the pre-commit hook to your project and cici update will run on every commit:

# .pre-commit-config.yaml
repos:
  # other hooks ...

  - repo: https://gitlab.com/brettops/tools/cici-tools
    rev: "0.2.5"
    hooks:
      - id: update

cici update pulls the latest GitLab Release for a pipeline project by date, so there are no versioning requirements for the upstream, but it does mean that the upstream needs to publish regular releases.

Bundle CI files with cici bundle

The cici bundle command splits a large CI file into many CI "bundles", one for each job. These bundled CI files have everything each job needs to run, so that each job can be consumed à la carte by downstream projects. It currently expands extends keywords, YAML anchors, and global variable declarations, with include expansion planned.

Let's use the brettops/pipelines/python pipeline as an example. It provides a large number of jobs that all depend on one or two base jobs. I won't attempt to reproduce its growing CI file, but here are a few jobs:

# .gitlab-ci.yml
# ...
python-black:
  extends: .python-base-small
  script:
    - $PYTHON -m pip install black
    - $PYTHON -m black --check --diff .

python-isort:
  extends: .python-base-small
  script:
    - $PYTHON -m pip install isort
    - $PYTHON -m isort --profile=black --check --diff .

python-mypy:
  extends: .python-base
  stage: test
  script:
    - *python-script-pip-install
    - $PYTHON -m pip install mypy
    - $PYTHON -m mypy "${PYTHON_PACKAGE}" --junit-xml report.xml
  artifacts:
    reports:
      junit: report.xml

python-pyroma:
  extends: .python-base-small
  script:
    - $PYTHON -m pip install pyroma
    - $PYTHON -m pyroma -n "$PYTHON_PYROMA_MINIMUM_RATING" .
  rules:
    - exists:
        - setup.py
# ...

If you'd like to use only some of the jobs here, but not all of them, you've got yourself a pickle. Splitting it into multiple CI files means that they'll need to depend on another file containing .python-base or .python-base-small. If you try to include more than one of these split files, GitLab will refuse, citing diamond inheritance. Ouch!

To overcome this, cici bundle will act as a compiler and build final versions that are independent from one another. Here's a bundled version of the python-pyroma job from above:

# pyroma.yml
stages:
  - test
  - build
  - deploy

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_OPEN_MERGE_REQUESTS
      when: never
    - when: always

variables:
  # ...

python-pyroma:
  stage: test
  image: "${CONTAINER_PROXY}python:${PYTHON_VERSION}-alpine"
  variables:
    GIT_DEPTH: "1"
    GIT_SUBMODULE_STRATEGY: "none"
    PIP_CONFIG_FILE: "$PYTHON_PIP_CONFIG_FILE"
    PYTHON: "/usr/local/bin/python3"
  before_script:
    - |-
      if [[ -n "$PYTHON_PYPI_GITLAB_GROUP_ID" ]] ; then
        export PYTHON_PYPI_DOWNLOAD_URL="https://${PYTHON_PYPI_USERNAME}:${PYTHON_PYPI_PASSWORD}@${CI_SERVER_HOST}/api/v4/groups/${PYTHON_PYPI_GITLAB_GROUP_ID}/-/packages/pypi/simple"
        echo "Pulling PyPI packages from GitLab group ID $PYTHON_PYPI_GITLAB_GROUP_ID"
      elif [[ -n "$PYTHON_PYPI_GITLAB_PROJECT_ID" ]] ; then
        export PYTHON_PYPI_DOWNLOAD_URL="https://${PYTHON_PYPI_USERNAME}:${PYTHON_PYPI_PASSWORD}@${CI_SERVER_HOST}/api/v4/projects/${PYTHON_PYPI_GITLAB_PROJECT_ID}/packages/pypi/simple"
        echo "Pulling PyPI packages from GitLab project ID $PYTHON_PYPI_GITLAB_PROJECT_ID"
      fi
    - |-
      if [[ -n "$PYTHON_PYPI_DOWNLOAD_URL" ]] ; then
      cat > "$PIP_CONFIG_FILE" <<EOF
      [global]
      index-url = ${PYTHON_PYPI_DOWNLOAD_URL}
      EOF
      fi
  script:
    - $PYTHON -m pip install pyroma
    - $PYTHON -m pyroma -n "$PYTHON_PYROMA_MINIMUM_RATING" .
  cache: {}
  rules:
    - exists:
        - setup.py

The above job is fully expanded and no longer depends on another CI file.

Adopting cici bundle on your own project isn't very complex. The first thing you'll need to do is move your existing shared CI file into a new .cici/ directory as .gitlab-ci.yml:

mkdir -p .cici/
git mv include.yml .cici/.gitlab-ci.yml

The contents of your CI file can mostly stay the same, with the caveat that cici ignores hidden jobs (those that start with .), so those ones will not be bundled.

Ensure that every job in your CI file starts with the path of the project. For example, for brettops/pipelines/ansible, the jobs must start with ansible-. This restriction may be lifted in a future release.

It is also wise to prefix all global variables with the project path. So for brettops/pipelines/ansible, your variables should start with ANSIBLE_ (though this is not currently enforced by the tool).

Now you can run cici bundle:

cici bundle
$ cici bundle
pipeline name: python
bundle names: ['black', 'isort', 'mypy', 'pyroma', 'pytest', 'setuptools', 'twine', 'vulture']
created black.yml
created isort.yml
created mypy.yml
created pyroma.yml
created pytest.yml
created setuptools.yml
created twine.yml
created vulture.yml

As noted above, this creates new CI files. Add the pre-commit hook to your project, and your CI bundles will be rebuilt every time you try to commit:

# .pre-commit-config.yaml
repos:
  # other hooks ...

  - repo: https://gitlab.com/brettops/tools/cici-tools
    rev: "0.2.5"
    hooks:
      - id: bundle

Now you can use as many or as few components of your reusable CI file as you like:

# .gitlab-ci.yml
include:
  - project: brettops/pipelines/python
    file:
      - black.yml
      - isort.yml
      - mypy.yml
      - pyroma.yml
      - pytest.yml
      - setuptools.yml
      - twine.yml
      - vulture.yml

...which is awesome!

This tool is definitely still in development, so there is, again, no guarantee that it will work for any particular use case. Also, not all GitLab CI syntax is supported yet, but cici will loudly complain when it encounters syntax it doesn't recognize. Here is the currently supported syntax.

I'm slowly working to adopt cici-tools across the BrettOps pipeline catalog, starting with the more complex ones that really, really need this functionality. The old shared pipeline files are much harder to maintain, and while they will be kept around on a best-effort basis, it is highly recommended to transition over.

Conclusion

These three tools make up the cici command currently, but there are more on the way. A lot of possibilities have opened up by having a GitLab CI structurizer written in Python, which means I can now manipulate CI files all day long, in a type-safe, immutable way, using my favorite programming language.

Over the years, I've written a lot of scattered tools and scripts to perform automated edits and analyses to GitLab CI files, but I anticipate this effort will unify my approach a lot. Things that I expect to fall out of this effort include, but are not limited to:

  • Automatic pinning of job image versions

  • Extensions to GitLab CI, like sourcing job scripts from standalone bash scripts rather than only YAML files

  • Bundle-time resolution of included CI pipelines to reduce blast radius of bad pipeline changes

  • Converting GitLab CI/CD to / from other formats to some degree, both to provide an onramp onto GitLab from other CI systems, and to use GitLab CI syntax as a "write once, run everywhere" format

  • Simulating a mostly complete GitLab CI pipeline locally

Who knows what will come next, but I'm excited to see where this tool goes. Drop me a line at [email protected] to tell me what you think! Happy coding!


Tags

#cicd #gitlab #pipelines #yaml