.NET Tutorial
Middle7 мин чтенияВопрос 3 из 10

Как настроить CI/CD для .NET проекта (GitHub Actions, GitLab CI, Azure DevOps)?

Конвейер целиком

CI — на каждый коммит

restore
(кэш NuGet)

build
-warnaserror

unit + integration
тесты

форматирование,
анализаторы, покрытие,
уязвимые пакеты

git push / PR

docker build → push
registry/app:sha-3f9a1c2

deploy dev
(автоматически)

deploy staging
+ миграции + smoke/e2e

Approval

deploy prod
(rolling / canary)

мониторинг,
авто-откат при ошибках

CI — на каждый коммит

restore
(кэш NuGet)

build
-warnaserror

unit + integration
тесты

форматирование,
анализаторы, покрытие,
уязвимые пакеты

git push / PR

docker build → push
registry/app:sha-3f9a1c2

deploy dev
(автоматически)

deploy staging
+ миграции + smoke/e2e

Approval

deploy prod
(rolling / canary)

мониторинг,
авто-откат при ошибках

От коммита до продакшена

GitHub Actions

.github/workflows/ci-cd.yml
name: ci-cd

on:
  push: { branches: [main] }
  pull_request:

env:
  DOTNET_VERSION: "9.0.x"
  IMAGE: ghcr.io/${{ github.repository_owner }}/shop-api

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}
          cache: true                                   # кэш NuGet по packages.lock.json
          cache-dependency-path: "**/packages.lock.json"
      - run: dotnet restore --locked-mode
      - run: dotnet format --verify-no-changes --no-restore
      - run: dotnet build -c Release --no-restore -warnaserror
      - run: dotnet test -c Release --no-build --collect:"XPlat Code Coverage" --logger trx --results-directory ./tests
        # интеграционные тесты с Testcontainers работают на ubuntu-latest без доп. настройки
      - run: dotnet list package --vulnerable --include-transitive 2>&1 | tee vuln.txt && ! grep -q "has the following vulnerable packages" vuln.txt
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: test-results, path: ./tests }

  image:
    needs: build-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions: { contents: read, packages: write }
    outputs: { tag: "${{ steps.meta.outputs.tag }}" }
    steps:
      - uses: actions/checkout@v4
      - id: meta
        run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: image
    runs-on: ubuntu-latest
    environment: staging                          # секреты и правила окружения
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy/migrate.sh "${{ secrets.STAGING_DB }}"           # migration bundle / SQL-скрипт
      - run: helm upgrade --install shop-api ./deploy/chart --set image.tag=${{ needs.image.outputs.tag }} -f deploy/values-staging.yaml --wait
      - run: ./deploy/smoke-test.sh https://staging.shop.example.uz

  deploy-prod:
    needs: [image, deploy-staging]
    runs-on: ubuntu-latest
    environment: production                       # required reviewers → ручное подтверждение
    steps:
      - uses: actions/checkout@v4
      - run: helm upgrade --install shop-api ./deploy/chart --set image.tag=${{ needs.image.outputs.tag }} -f deploy/values-prod.yaml --wait --atomic

--atomic у Helm откатывает релиз, если раскатка не завершилась успешно.

GitLab CI

.gitlab-ci.yml
stages: [build, test, package, deploy]

variables:
  DOTNET_IMAGE: mcr.microsoft.com/dotnet/sdk:9.0
  NUGET_PACKAGES: $CI_PROJECT_DIR/.nuget

default:
  image: $DOTNET_IMAGE
  cache:
    key: { files: ["**/packages.lock.json"] }
    paths: [.nuget/]

build:
  stage: build
  script:
    - dotnet restore --locked-mode
    - dotnet build -c Release --no-restore -warnaserror

test:
  stage: test
  services: [docker:dind]                      # для Testcontainers
  variables: { DOCKER_HOST: tcp://docker:2375, DOCKER_TLS_CERTDIR: "" }
  script:
    - dotnet test -c Release --logger "junit;LogFilePath=../../test-results/{assembly}.xml"
  artifacts:
    reports: { junit: test-results/*.xml }

package:
  stage: package
  image: docker:27
  services: [docker:dind]
  rules: [{ if: $CI_COMMIT_BRANCH == "main" }]
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

deploy_prod:
  stage: deploy
  environment: production
  when: manual                                  # ручной запуск
  rules: [{ if: $CI_COMMIT_BRANCH == "main" }]
  script:
    - helm upgrade --install shop-api ./chart --set image.tag=$CI_COMMIT_SHORT_SHA --wait --atomic

Azure DevOps (кратко)

azure-pipelines.yml
trigger: [main]
pool: { vmImage: ubuntu-latest }
stages:
  - stage: Build
    jobs:
      - job: BuildTest
        steps:
          - task: UseDotNet@2
            inputs: { version: 9.0.x }
          - script: dotnet test -c Release --logger trx --collect:"XPlat Code Coverage"
          - task: PublishTestResults@2
            inputs: { testResultsFormat: VSTest, testResultsFiles: "**/*.trx" }
          - task: Docker@2
            inputs: { command: buildAndPush, repository: shop-api, containerRegistry: acr-connection, tags: $(Build.BuildId) }
  - stage: Prod
    dependsOn: Build
    jobs:
      - deployment: Deploy
        environment: production                   # approvals и checks настраиваются на окружении
        strategy: { runOnce: { deploy: { steps: [ { script: "helm upgrade --install ..." } ] } } }

Сравнение платформ

GitHub Actions GitLab CI Azure DevOps
Конфигурация .github/workflows/*.yml .gitlab-ci.yml azure-pipelines.yml
Единицы workflow → jobs → steps pipeline → stages → jobs pipeline → stages → jobs → steps/tasks
Переиспользование Actions из marketplace, reusable workflows include, templates, CI/CD components Templates, extensions
Раннеры GitHub-hosted, self-hosted Shared, self-hosted runners Microsoft-hosted, self-hosted agents
Окружения и approvals Environments + required reviewers Environments + manual / protected Environments + approvals & checks
Сильная сторона Экосистема, open source Всё-в-одном (repo, registry, CI) Интеграция с Azure, корпоративные процессы

Хорошие практики

  • Быстрый CI: кэш NuGet (lock-файлы + --locked-mode), кэш слоёв Docker, параллельные джобы, разделение unit/integration, только затронутые проекты в монорепо.
  • Воспроизводимость: фиксированные версии SDK (global.json), lock-файлы пакетов, теги образов по SHA (не latest).
  • Quality gates: форматирование (dotnet format), анализаторы (TreatWarningsAsErrors), покрытие нового кода, SAST (CodeQL), сканирование образов (Trivy), уязвимые пакеты.
  • Безопасность: секреты в хранилище CI/окружения, OIDC-федерация к облаку вместо долгоживущих ключей, минимальные permissions, защита основной ветки (обязательные проверки, review).
  • Деплой: миграции отдельным шагом, стратегия раскатки (rolling/blue-green/canary), smoke-тесты, автоматический откат, уведомления.
  • Trunk-based + feature flags — частые маленькие релизы безопаснее редких больших.

Вопросы на засыпку

Чем Continuous Delivery отличается от Continuous Deployment?

Delivery — каждое изменение готово к выпуску (прошло все проверки, развернуто на staging), но выпуск в прод — ручное решение. Deployment — каждое изменение, прошедшее конвейер, автоматически уходит в прод без ручного шага.

Почему нужно «собирать один раз»?

Если собирать отдельно для staging и prod, в прод может попасть не тот код/зависимости, что тестировались. Один артефакт (образ с неизменяемым тегом) гарантирует: в проде ровно то, что прошло проверки; различается только конфигурация окружения.

Где хранить секреты для пайплайна?

В защищённых переменных/секретах платформы (GitHub Secrets, GitLab CI variables masked/protected, Azure DevOps variable groups + Key Vault), привязанных к окружениям. Лучше — OIDC workload identity federation к облаку (без статических ключей). Никогда — в YAML или репозитории.

Как ускорить интеграционные тесты в CI?

Один контейнер БД на коллекцию тестов, Respawn вместо пересоздания, параллельные коллекции с отдельными базами, кэш Docker-образов, запуск медленных тестов отдельной джобой параллельно с unit-тестами.

Итог

CI проверяет каждое изменение (build, тесты, анализ), CD доставляет один и тот же неизменяемый артефакт по окружениям с миграциями, smoke-тестами, approvals и откатом. Платформы (GitHub Actions, GitLab CI, Azure DevOps) различаются синтаксисом и экосистемой, но принципы одинаковы: быстро, воспроизводимо, безопасно и автоматически.