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.
# 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.
{
"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
- Azure Pipelines documentation. https://learn.microsoft.com/en-us/azure/devops/pipelines/
- SonarQube documentation. https://docs.sonarsource.com/sonarqube/
- Angular CLI build. https://angular.dev/tools/cli/build
- npm ci. https://docs.npmjs.com/cli/v10/commands/npm-ci