Voltar ao Blog
Angular5 de fevereiro de 202611 min de leitura

CI/CD com Azure Pipelines para Angular: Um Setup Prático

DR

Doug Ribeiro

11 min de leitura

A maioria dos projetos Angular em que entrei em nível enterprise tinha alguma versão de CI/CD. A diferença entre uma pipeline que tecnicamente roda e uma que realmente pega problemas reais antes de chegar a produção é enorme. Depois de anos em ambientes de serviços financeiros, aqui está um setup a título de exemplo que prova seu valor.

Por Que Azure Pipelines

No BMO, Azure Pipelines e SonarQube faziam parte do setup do time. Em organizações que rodam no Azure DevOps, o Pipelines integra diretamente com os repos, work items e artifact feeds já em uso, e a decisão geralmente é tomada antes de um desenvolvedor front-end entrar no projeto. Os princípios aqui se traduzem para GitHub Actions ou GitLab CI com mudanças mínimas, então as decisões estruturais valem a pena entender independente da ferramenta.

Os Quatro Estágios Que Realmente Importam

  • Lint: pegar problemas de estilo e análise estática antes dos testes rodarem. Feedback rápido, barato de corrigir. Falha rápido se alguém deu push sem rodar localmente.
  • Test: testes unitários com thresholds de cobertura aplicados. Sem threshold configurado significa sem gate. Threshold sem enforcement é teatro.
  • Build: builds específicos por ambiente usando fileReplacements do Angular. Não detecção de ambiente em runtime, não globals injetados. Substituição em tempo de build, resolvida antes do bundle ser escrito.
  • Deploy: upload de artefato para Azure Blob ou App Service, com uma aprovação manual no ambiente de produção. Pelo menos um aprovador de fora do time que escreveu a mudança.
Install (npm ci) Lint Test + cobertura SonarQube Build Deploy
yaml
# Illustrative azure-pipelines.yml: a generic starting point, not any employer's pipeline
trigger:
  branches:
    include:
      - main
      - develop

pool:
  vmImage: 'ubuntu-latest'

variables:
  nodeVersion: '20.x'
  npmCacheDir: $(Pipeline.Workspace)/.npm
  buildEnvironment: 'production'  # or 'staging'; must match a configuration in angular.json
  sonarConnection: 'sonarqube-service-connection'
  azureConnection: 'azure-service-connection'
  webAppName: 'my-angular-app-prod'

stages:
  - stage: Lint
    jobs:
      - job: LintJob
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - task: Cache@2
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: 'npm | "$(Agent.OS)"'
              path: $(npmCacheDir)
          - script: npm ci --cache $(npmCacheDir)
            displayName: 'Install dependencies'
          - script: npm run lint
            displayName: 'Run ESLint'

  - stage: Test
    dependsOn: Lint
    jobs:
      - job: TestJob
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - task: Cache@2
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: 'npm | "$(Agent.OS)"'
              path: $(npmCacheDir)
          - script: npm ci --cache $(npmCacheDir)
          # Task major versions track the SonarQube extension installed in your org. Check its
          # marketplace page before copying the @N suffixes.
          - task: SonarQubePrepare@6
            displayName: 'Prepare SonarQube analysis'
            inputs:
              SonarQube: $(sonarConnection)
              scannerMode: 'CLI'
              configMode: 'manual'
              cliProjectKey: 'my-angular-app'
              cliSources: 'src'
              extraProperties: |
                sonar.javascript.lcov.reportPaths=coverage/lcov.info
                sonar.qualitygate.wait=true
          - script: npm test -- --ci --coverage
            displayName: 'Run unit tests'
          # Jest writes these files only if 'cobertura' and 'lcov' are in its coverageReporters
          - task: PublishCodeCoverageResults@2
            inputs:
              summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage/**/cobertura-coverage.xml'
          - task: SonarQubeAnalyze@6
            displayName: 'Run SonarQube analysis'
          # This is the step that turns a red quality gate into a failed pipeline on Azure DevOps
          - task: SonarQubePublish@6
            displayName: 'Publish Quality Gate Result'
            inputs:
              pollingTimeoutSec: '300'

  - stage: Build
    dependsOn: Test
    jobs:
      - job: BuildJob
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - task: Cache@2
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: 'npm | "$(Agent.OS)"'
              path: $(npmCacheDir)
          - script: npm ci --cache $(npmCacheDir)
          - script: npm run build -- --configuration=$(buildEnvironment)
            displayName: 'Build Angular app'
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: dist
              artifact: angular-dist

  - stage: Deploy
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      # A deployment job, not a plain job: the environment is what carries the approval
      - deployment: DeployProduction
        # Approvals and checks are configured on the environment in Azure DevOps, not in this file
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: angular-dist
                - task: AzureWebApp@1
                  displayName: 'Deploy to App Service'
                  inputs:
                    azureSubscription: $(azureConnection)
                    appType: 'webAppLinux'
                    appName: $(webAppName)
                    # The application builder writes dist/<project>/browser
                    package: '$(Pipeline.Workspace)/angular-dist/my-angular-app/browser'

Fazendo Cache do npm Corretamente

A task Cache@2 com package-lock.json como chave pula a maior parte do download de dependências em toda execução, e em um projeto Angular grande isso são minutos por execução. Multiplique isso por cada PR de uma semana e você está recuperando tempo significativo de engenheiro. O cache só é invalidado quando as dependências realmente mudam, que é exatamente o que você quer.

Use npm ci com a flag --cache, não npm install. npm ci garante uma instalação limpa a partir do lockfile. npm install pode silenciosamente resolver de forma diferente e introduzir divergência entre builds locais e do CI. Já cacei bugs que se originaram dessa divergência, e é por isso que trato npm ci como inegociável no CI.

Builds Específicos por Ambiente via fileReplacements

O fileReplacements do Angular no angular.json é o mecanismo correto para configuração de ambiente. Não checks de window.location em runtime. Não globals injetados. Substituição em tempo de build significa que a configuração de ambiente errada não pode chegar a produção porque foi compilada fora no momento do build.

json
{
  "configurations": {
    "staging": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.staging.ts"
        }
      ],
      "optimization": true,
      "sourceMap": false
    },
    "production": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.prod.ts"
        }
      ],
      "optimization": true,
      "sourceMap": false,
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "500kb",
          "maximumError": "1mb"
        }
      ]
    }
  }
}

SonarQube Como Gate Bloqueante

Em um ambiente regulado, SonarQube ou um gate equivalente de análise estática não é opcional. O erro que mais vejo é integrar SonarQube como um step de relatório que ninguém revisa. Um quality gate que não bloqueia a pipeline não é um gate. É uma sugestão.

As tasks SonarQubePrepare, SonarQubeAnalyze e SonarQubePublish ficam no estágio de Test acima. Publicar o resultado não para nada sozinho: coloque sonar.qualitygate.wait=true nas propriedades extras do step de prepare para a pipeline falhar em um gate vermelho. No Azure DevOps essa falha aparece pela task Publish Quality Gate Result, que espera o gate e então falha o build, e não pelo step de análise. Depois configure o quality gate no SonarQube para falhar se a cobertura cair abaixo do threshold ou se novos issues críticos de segurança aparecerem. A primeira vez que ele bloqueia um merge com um achado de segurança real, o time para de tratar como ruído.

Um quality gate que não bloqueia a pipeline não é um gate. É uma sugestão. Trate como bloqueante desde o primeiro dia ou não se dê ao trabalho de configurar.

Gates de Aprovação para Produção

O estágio de Deploy precisa de um requisito de aprovação manual para o ambiente de produção. No Azure Pipelines isso é uma aprovação de recurso Environment, configurada no ambiente e não no YAML, que é por isso que o job de deployment acima só o nomeia. Minha referência é pelo menos um aprovador de fora do time que escreveu a mudança. Quantos aprovadores e quais papéis assinam é uma decisão de política da organização, não de quem escreve a pipeline. A aprovação também cria uma trilha de auditoria que times de compliance exigem, e ter isso desde o início significa que você não está retrofitando governança em uma pipeline que já tem hábitos de deploy arriscados.

O setup descrito aqui é um exemplo, não uma cópia da pipeline de nenhum empregador. Os seus detalhes vão diferir, mas a estrutura não muda: lint rápido, teste com thresholds aplicados, build contra configurações explícitas de ambiente, deploy com gates humanos. Essa sequência pega os problemas que importam e não desacelera o time nos problemas que não importam.

Referências

AngularAzure PipelinesCI/CDDevOps