Volver al Blog
Angular5 de febrero de 202611 min de lectura

CI/CD con Azure Pipelines para Angular: Una configuración práctica

DR

Doug Ribeiro

11 min de lectura

La mayoría de los proyectos Angular a los que me he unido a nivel enterprise ha tenido alguna versión de CI/CD. La brecha entre un pipeline que técnicamente corre y uno que realmente detecta problemas reales antes de producción es enorme. Después de años en entornos de servicios financieros, esta es una configuración a modo de ejemplo que se gana su lugar.

Por qué Azure Pipelines

En BMO, Azure Pipelines y SonarQube eran parte del setup del equipo. En organizaciones sobre Azure DevOps, Pipelines se integra directamente con los repos, work items, y feeds de artefactos ya en uso, y la decisión usualmente ya está tomada antes de que un developer front-end se una al proyecto. Los principios aquí se traducen a GitHub Actions o GitLab CI con cambios mínimos, así que las decisiones estructurales valen la pena entenderlas independientemente de las herramientas.

Las cuatro etapas que realmente importan

  • Lint: detecta problemas de estilo y análisis estático antes de que corran los tests. Feedback rápido, barato de arreglar. Falla rápido si alguien pusheó sin ejecutar localmente.
  • Test: tests unitarios con umbrales de cobertura aplicados. Sin umbral configurado no hay gate. Umbral sin enforcement es teatro.
  • Build: builds específicos por entorno usando fileReplacements de Angular. No detección de entorno en runtime, no globals inyectados. Sustitución en tiempo de build, resuelta antes de que se escriba el bundle.
  • Deploy: upload de artefactos a Azure Blob o App Service, con una aprobación manual sobre el entorno de producción. Al menos un aprobador externo al equipo que escribió el cambio.
Instalar (npm ci) Lint Tests + coverage 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'

Cacheando npm correctamente

La tarea Cache@2 con package-lock.json como clave se salta la mayor parte de la descarga de dependencias en cada ejecución, y en un proyecto Angular grande eso son minutos por corrida. Multiplica eso por cada PR de una semana y estás recuperando tiempo significativo de ingenieros. El caché solo se invalida cuando las dependencias realmente cambian, que es exactamente lo que quieres.

Usa npm ci con el flag --cache, no npm install. npm ci garantiza una instalación limpia desde el lockfile. npm install puede resolver silenciosamente diferente e introducir drift entre builds locales y de CI. He perseguido bugs que se originaron por este drift, y por eso trato npm ci como no negociable en CI.

Builds específicos por entorno vía fileReplacements

fileReplacements de Angular en angular.json es el mecanismo correcto para configuración de entorno. No checks de window.location en runtime. No globals inyectados. La sustitución en tiempo de build significa que la configuración de entorno incorrecta no puede llegar a producción porque se compiló fuera en tiempo de 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

En un entorno regulado, SonarQube o un gate equivalente de análisis estático no es opcional. El error que veo más seguido es integrar SonarQube como un paso de reporte que nadie revisa. Una quality gate que no bloquea el pipeline no es una gate. Es una sugerencia.

Las tareas SonarQubePrepare, SonarQubeAnalyze y SonarQubePublish van en la etapa de Test de arriba. Publicar el resultado no detiene nada por sí solo: pon sonar.qualitygate.wait=true en las propiedades extra del paso de prepare para que el pipeline falle ante una gate roja. En Azure DevOps esa falla aparece a través de la tarea Publish Quality Gate Result, que espera la gate y luego hace fallar el build, no a través del paso de análisis. Después configura la quality gate en SonarQube para que falle si la cobertura baja del umbral o si aparecen nuevos problemas críticos de seguridad. La primera vez que bloquea un merge con un hallazgo de seguridad real, el equipo deja de tratarlo como ruido.

Una quality gate que no bloquea el pipeline no es una gate. Es una sugerencia. Trátala como bloqueante desde el día uno o no te molestes en configurarla.

Gates de aprobación para producción

La etapa de Deploy necesita un requisito de aprobación manual para el entorno de producción. En Azure Pipelines eso es una aprobación de recurso Environment, configurada sobre el entorno y no en el YAML, que es por lo que el job de deployment de arriba solo lo nombra. Mi línea base es al menos un aprobador externo al equipo que escribió el cambio. Cuántos aprobadores y qué roles firman es una decisión de política de la organización, no algo que define el autor del pipeline. La aprobación también crea un audit trail que los equipos de compliance requieren, y tenerlo desde el inicio significa que no estás retrofitteando gobernanza en un pipeline que ya tiene hábitos de deployment riesgosos.

La configuración descrita aquí es ilustrativa, no una copia del pipeline de ningún empleador. Tus detalles serán distintos, pero la estructura no cambia: lint rápido, test con umbrales aplicados, build contra configuraciones de entorno explícitas, deploy con gates humanas. Esa secuencia detecta los problemas que importan y no ralentiza al equipo con los que no.

Referencias

AngularAzure PipelinesCI/CDDevOps