How to organize pipeline files in your projects

Lately I’ve been working on a complex project, and, besides development, I’m also handling the DevOps side. The project repository is on GitLab (a self-hosted instance) and before working on the pipeline I set up the GitLab runners to execute the pipeline (I discussed this in this article).

In this article I’ll share my recommendations for organizing pipeline files in a “complex” project in the best way possible. The project will be in .NET 10 but the concepts apply to any technology stack.

The examples we’ll see in this article work particularly well in contexts where the pipeline runs automatically. GitLab also allows you to execute a pipeline manually (entering parameters before starting it), but from my experience I recommend using more advanced tools (e.g., Jenkins) for that use case.

Basic structure of the gitlab-ci.yml file

Pipelines in GitLab are written in YAML and are executed when the gitlab-ci.yml file is found in the project root (it also works with a different name, but needs to be configured). In a “simple” project, putting everything in the gitlab-ci.yml file will suffice and it might look like this:

.gitlab-ci.yml
#########################################
# Pipeline stages
#########################################
stages:
  - build
  - test

#########################################
# .NET Build
#########################################
dotnet-build:
  stage: build
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    # Custom variables
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET Environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour

#########################################
# .NET Test execution
#########################################
dotnet-tests:
  stage: test
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    - build_dotnet
  dependencies:
    # this step depends from build step
    - build
  script:
    - echo "Start tests..."
    - dotnet test --configuration $BUILD_CONFIGURATION --verbosity minimal
    - echo "Tests completed"
  allow_failure: false

This simple script will result in this pipeline:

In the first part of the script, the various stages are defined (which correspond to the rectangles). Each step (dotnet-build and dotnet-tests) will belong to a stage (you can have multiple steps within a single stage).

Shared steps

In a complex project it’s likely that many steps (regardless of stage) share some elements. Rewriting the same things is wrong, because if we need to make a change it will have to be done in multiple places. However, there is a way to create shared pipeline steps that can be reused by other steps (which extend them).

Let’s take the build step we just saw as an example:

YAML
dotnet-build:
  stage: build
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    # Custom variables
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET Environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour

This simple script can be split in two, a “shareable” part and an “official” one.

YAML
##############################################
# Shared build step
##############################################
.dotnet-build-shared:
  stage: build
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    # Custom variables with DEFAULT value
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET Environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour

##############################################
# Build step
##############################################
dotnet-build:
    extends: .dotnet-build-shared
  variables:
    # Variable value override
    BUILD_CONFIGURATION: "Release"

As you can see, we now have two steps: .dotnet-build-shared is the shared step, any step that needs a project build before executing actions can use it. The step we had before has instead become much smaller, we’ve added the extends keyword and the override of the variable used in the build phase.

It’s good practice to always define a default value for variables.

Additionally, you can notice how the shared step has a . in front of the name: this is not by chance, the dot in front of the name defines a “hidden” step, which cannot be called directly but must always be extended by another step.

Shared steps can also be centralized in a dedicated repository, but we’ll talk about that in another article to stay on topic πŸ™‚

Organizing files in the project

The example we’re looking at is deliberately simple, but how do you manage a complex pipeline, with maybe ten stages and twenty parametric steps? My recommendation is to create dedicated .yml files, grouped in the .gitlab/pipeline directory in the project root (create it if it doesn’t exist). The gitlab-ci.yml file will always remain as the pipeline entry point, but we’ll organize the various pipeline steps divided by scope.

For this example the files could be organized this way:
πŸ“„ gitlab-ci.yml
πŸ“ .gitlab/
└── πŸ“ pipeline/
β”œβ”€β”€ πŸ“„ build.yml
β”œβ”€β”€ πŸ“„ shared.yml
└── πŸ“„ test.yml

Let’s now look at the file contents:

.gitlab/shared.yml
##############################################
# Shared .NET 10 steps
##############################################
.dotnet-10-shared:
  image: mcr.microsoft.com/dotnet/sdk:10.0
  tags:
    # tags are used for gitlab runner
    - build_dotnet 
  variables:
    # .NET env variables
    DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
    DOTNET_CLI_TELEMETRY_OPTOUT: "true"

##############################################
# Shared build step
##############################################
.dotnet-build-shared:
  extends: .dotnet-10-shared # --> A shared step can extend another shared step!
  variables:
    # Custom variables with DEFAULT value
    BUILD_CONFIGURATION: "Release"
    ARTIFACTS_PATH: "${CI_PROJECT_DIR}/bin/${BUILD_CONFIGURATION}/"
  before_script:
    - echo ".NET environment informations"
    - dotnet --version
    - cd $CI_PROJECT_DIR
  script:
    - dotnet restore
    - dotnet build --configuration $BUILD_CONFIGURATION --no-restore
    - echo "Build completed!"
  allow_failure: false
  artifacts:
    paths:
      - $ARTIFACTS_PATH
    expire_in: 1 hour
.gitlab/build.yml
##############################################
# Build step
##############################################
dotnet-build:
    extends: .dotnet-build-shared
    stage: build
.gitlab/test.yml
#########################################
# .NET Test execution
#########################################
dotnet-tests:
  extends: .dotnet-10-shared
  stage: test
  dependencies:
    # this step depends from build step
    - dotnet-build
  script:
    - echo "Start tests..."
    - dotnet test --configuration $BUILD_CONFIGURATION --verbosity minimal
    - echo "Tests completed"
  allow_failure: false
.gitlab-ci.yml
# include all files in .gitlab directory
include:
Β  - local: .gitlab/pipeline/*.yml
Β    
variables:
    #Global variable
    BUILD_CONFIGURATION: "Release"

As you can see, our pipeline file has become much simpler, even trivial: it only contains an instruction that includes all yaml files in the .gitlab/pipeline directory and the definition of a global variable (used in both the test step and the pipeline step).

Additionally, in the complete example I’ve shown you, you can notice that I made a further improvement in the shared.yml file: considering that both the shared build step (.dotnet-build-shared) and the test step (dotnet-tests) shared stage, docker image, tags and variables, I created a new shared step to factor out these common elements.

When GitLab finds the .gitlab-ci.yml file it imports all the yaml files defined at the beginning (with the include instruction) and creates a large file composed of the sum of all these files. Subsequently it starts executing all the steps that correspond to the first stage, until the pipeline completes.

In a more complex and structured project, assuming a more articulated pipeline, the directory tree could be as follows:

πŸ“„ gitlab-ci.yml
πŸ“ .gitlab/
└── πŸ“ pipeline/
β”œβ”€β”€ πŸ“„ build.yml
β”œβ”€β”€ πŸ“„ deploy.yml
β”œβ”€β”€ πŸ“„ docker.yml
β”œβ”€β”€ πŸ“„ quality.yml
β”œβ”€β”€ πŸ“„ template.yml
β”œβ”€β”€ πŸ“„ test.yml
└── πŸ“„ utils.yml

Managing a pipeline in complex projects obviously isn’t limited to just this! There are many other aspects I haven’t covered in this article, from caching to conditional steps, etc. We’ll tackle these other topics in dedicated posts!

Conclusion

Order in a project isn’t just an aesthetic matter, but about being prepared for the project’s growth and evolution. In development, we’ve invented clean architecture and other paradigms to solve the problem of chaos as the project grows. Obviously there are no defined patterns for organizing a pipeline, but keeping them organized allows you to professionally manage the project’s growth, without having to say the famous phrase “I don’t have time now, I’ll do it tomorrow” (which nobody ever believed anyway πŸ˜…).

I hope you enjoyed this short article, see you next time!

Share this article
Shareable URL
Prev Post

Angular config: from environment.ts to http entpoint

Next Post

From OneNote to Obsidian: how I built (and migrated) my second brain

Read next