GitLab CI/CD Series: Building .NET API Application and EF Core Migration Bundle

This is the second post in the GitLab CI/CD Series, where we'll be setting up a build & deploy pipeline using GitLab CI/CD. In the first post, we focused on building an Angular application and capturing the build output as an artifact.

GitLab CI/CD Series Table of Contents

If you've never worked with GitLab CI/CD before, the previous post is a great thing to have a look through, as it covers most of the basics.

Recap & summary

If you're up-to-date with where the last article ended, feel free to skip this section.

Here are the assumptions about the project:

  • The project is hosted on GitLab
  • One repository contains both .NET and Angular applications
  • The Docker GitLab Runner executor will be used
  • The CI/CD pipeline will consist of the following stages: build, staging, and production, with the last two being all about deployment to different environments
  • The project structure is:
client/
└── webapp/
    ├── src/
    ├── angular.json
    ├── package.json
    └── tsconfig.json
server/
└── MyApp/
    ├── src/
    │   ├── Api/
    │   │   ├── Infrastructure
    │   │   ├── Controllers
    │   │   ├── Api.csproj
    │   │   ├── appsettings.json
    │   │   ├── Program.cs
    │   │   └── Startup.cs
    │   ├── Logic/
    │   │   ├── Services
    │   │   └── Logic.csproj
    │   └── Data/
    │       ├── Models
    │       ├── DbContext
    │       └── Data.csproj
    └── MyApp.sln
Text

And this the the current look of the gitlab-ci.yml file:

stages:
    - build
    
build-frontend:
    stage: build
    image: node:16.9.0
    rules:
        - changes:
        - 'client/**/*'
    cache:
        key:
            prefix: 'frontend'
            files:
                - 'client/webapp/package-lock.json'
        paths:
        - 'client/webapp/node_modules'
    before_script:
        - 'cd client/webapp/'
        - 'npm ci'
    script:
        - 'npm run build'
    artifacts:
        name: 'frontend-${CI_COMMIT_SHORT_SHA}'
        paths:
        - 'client/webapp/dist'
YAML

To sum up: this job is created when there are any changes in the client/ directory. First, the runner checks if there's a cache based on the cache.key property that's generated from the contents of package-lock.json. If the cache is there, it's downloaded and extracted. Then the before_script and script sections are executed. If everything goes well, an artifact is created containing the build output, and the cached paths are re-uploaded back to the caching service.

Building .NET Application

Building a .NET application will seem very similar to the frontend build. This will be the starting point:

variables:
  RUNTIME_ID: 'win-x86'

build-backend:
    stage: build
    image: mcr.microsoft.com/dotnet/sdk:6.0
    rules:
      - changes:
        - 'server/**/*'
    cache:
      key: 'backend'
      paths:
        - '.nuget'
    before_script:
    - 'dotnet restore server/MyApp/src/Api/Api.csproj --packages .nuget/ --runtime ${RUNTIME_ID}'
    - 'mkdir -p ./tmp/build-artifact'
    script:
    - 'dotnet publish server/MyApp/src/Api/Api.csproj -c Release --no-restore --runtime ${RUNTIME_ID} --no-self-contained -o ./tmp/build-artifact'
    artifacts:
      name: "backend-$CI_COMMIT_SHORT_SHA"
      paths:
        - 'tmp/build-artifact'

Almost everything here should feel familiar. One new thing is the introduction of the variables section. The name is pretty self-explanatory, but it might seem unusual to define their values in the YAML file. After all, how variable will they be if they are hard-coded like that? That's a good point, but in this context, they should be treated more like constants. They will be used in more than one place, and their values must be consistent throughout the whole file. Therefore it's a good practice to define a key for them. Naturally, it is possible to define variables outside of the gitlab-ci.yaml file. Read more about that and other variable-related features at GitLab CI/CD variables.

Another thing that might draw attention is the explicit specification of the target runtime. This is because the mcr.microsoft.com/dotnet/sdk image is a Linux-based container, and to have the application run on Azure App Service, the binaries must target Windows.

Entity Framework Core Migration Bundles

With the release of Entity Framework Core 6, a new DevOps-friendly way to apply database migrations was added. In the past, the migrations were either:

  • exported as raw SQL scripts and executed on the database server,
  • applied by the dotnet-ef tool with a specified connection string,
  • applied during application startup.

Those options worked, but each one had its own set of problems. The first one required all migrations to be applied sequentially. The second one required the application source code to be available at the deployment stage and needed the .NET Core SDK to be installed. The last one caused problems in distributed setups where multiple instances may try to apply the migrations at the same time, causing all sorts of issues.

The new approach tries to solve those issues. It's called Migration Bundle, and it's a self-contained executable with everything needed to run migrations. It solves the previously mentioned problems by:

  • figuring out which migrations have already been applied and makes only the necessary changes to the database,
  • not requiring the .NET SDK to be installed or the application source code to be available,
  • decoupling migrations from the application code preventing any race conditions in distributed setups.

For a great introduction and in-depth discourse regarding the feature, visit these two links:

Now it's time to update the gitlab-ci.yml file:

variables:
  RUNTIME_ID: 'win-x86'
  MIGRATIONS_RUNTIME_ID: 'alpine-x64'

build-backend:
    stage: build
    image: mcr.microsoft.com/dotnet/sdk:6.0
    rules:
      - changes:
        - 'server/**/*'
    cache:
      key: 'backend'
      paths:
        - '.nuget'
    before_script:
    - 'dotnet restore server/MyApp/src/Api/Api.csproj --packages .nuget/ --runtime ${RUNTIME_ID}'
    - 'dotnet tool install --global dotnet-ef --version 6.0.5'
    - 'mkdir -p ./tmp/build-artifact'
    - 'mkdir -p ./tmp/migrations-artifact'
    script:
    - 'dotnet publish server/MyApp/src/Api/Api.csproj -c Release --no-restore --runtime ${RUNTIME_ID} --no-self-contained -o ./tmp/build-artifact'
    - 'cd server/MyApp && dotnet ef migrations bundle --target-runtime ${MIGRATIONS_RUNTIME_ID} --self-contained -o ../../tmp/migrations-artifact/efbundle'
    artifacts:
      name: "backend-$CI_COMMIT_REF_NAME"
      paths:
        - 'tmp/build-artifact'
        - 'tmp/migrations-artifact'

Two things were added - installation of the dotnet-ef tool and a second command in the script section responsible for building the Migration Bundle. You will again notice the explicit target runtime specification. That's to make sure the executable will have all the required things to run during the deployment stage.

Side note: as of writing this post, there is a small bug in the dotnet-ef utility. You might get an error like this:

Build failed. Use --verbose to see errors.

warning MSB3026: Could not copy "obj\Release\net6.0\Api.dll" to "bin\Release\net6.0\MyApp.dll".
Beginning retry 1 in 1000ms. 
The process cannot access the file 'C:\MyApp\server\MyApp\src\Api\bin\Release\net6.0\Api.dll' because it is being used by another process.
[C:\MyApp\server\MyApp\src\Api\Api.csproj]

Thankfully, this issue was reported at Bundle-Migration errors with "cannot access file...being used by another process" #25555 and should be fixed in the future. As a workaround, add a parameter --configuration Bundle to the command and the error will be gone. The updated script entry in the YAML should therefore look like this:

cd server/MyApp && dotnet ef migrations bundle --target-runtime ${MIGRATIONS_RUNTIME_ID} --self-contained -o ../../tmp/migrations-artifact/efbundle --configuration Bundle

And that's done - both the built application and the EFCore Migration Bundle should be packaged into a job artifact and available for deployment!

Cover photo by C Dustin on Unsplash