Introducción
TL;DR
Existen muchos sistemas para automatizar pipelines de CI/CD, desde GitHub Actions y GitLab CI/CD hasta soluciones self-hosted como Jenkins. Además, existen soluciones de los propios hiperescalares que nos dan la infraestructura completa para almacenar nuestro código y desplegar nuestros productos. Lo cierto es que Bitbucket Pipelines cubre un pequeño nicho de mercado que, con el paso del tiempo, se está dejando ver de vez en cuando (pero para nada tiene tanta comunidad como los anteriores). Siendo sinceros, si nuestro codebase está en Bitbucket, tiene sentido usarlo. Estas automatizaciones del SDLC y sus canalizaciones se orquestan definiendo diferentes entidades mediante un fichero YAML en la raíz del repositorio. Su sintaxis ha evolucionado bastante en poco tiempo y es habitual toparse con proyectos con una programación de este YAML obsoleta.
Aquí no busco hacer una guía de sintaxis sino recoger ejemplos sobre casos de uso reales donde aplico las automatizaciones de SDLC más comunes, para dar ese primer contexto necesario y empezar a programar nuestros pipelines.
Qué podemos hacer
Prácticamente cualquier cosa que podamos lograr con sus homónimos. Podemos programar pipelines asociados a condiciones de arranque basadas en eventos de Git, programáticas o ejecutables a demanda. Además de que los manuales pueden recibir configuración adicional en la propia ejecución del pipeline como parámetros de entrada (como el típico "número de versión para el que queremos programar nuestro código"). Está basado en imágenes de Docker por cada step (figura de la cual hablaré más adelante); puede ejecutarse en el contexto de una imagen, por ejemplo node:22-alpine, para usar todas sus dependencias. A nivel funcional, dentro de nuestro pipeline podremos hacer cualquier cosa alcanzable con scripting en Bash a nivel de usuario (no root).
Además, dispone de un sistema donde podemos definir nuestros propios stages (por ejemplo, los típicos staging o production) denominando deployments con sus propias variables de entorno, así como variables de entorno generales a todo nuestro repositorio.
Es una solución que Atlassian proporciona de manera cloud y que se monetiza por consumo de minutos, teniendo una cierta cantidad gratuita. Además de disponer de características más premium que solo están disponibles para cuentas de pago.
Qué pinta tiene un bitbucket-pipelines.yml
El fichero que domina todo es el bitbucket-pipelines.yml. En él crearemos todos nuestros steps y condiciones de arranque para ejecutar los pipelines de nuestro proyecto. Voy a comenzar a partir de un YAML muy simple para introducir la sintaxis que, ya adelanto, es sencilla.
image: node:22-alpine
pipelines:
default:
- step:
name: Saluda y comprueba versiones
script:
- echo "Hola desde Bitbucket Pipelines!"
- node --version
- npm --versionVoy a analizar qué hace el anterior código sobre nuestro proyecto:
- De manera general, definimos una imagen que usará cada step para ejecutarse (en nuestro caso,
node:22-alpine). - En la sección de
pipelinesdefinimos esas condiciones de comienzo para nuestras automatizaciones. En este caso, con el uso dedefault, estamos diciendo que se ejecuta con cada push a nuestro codebase en Bitbucket; es decir, cada push a cualquier rama va a ejecutar esto. - Dentro de esa condición de
defaultdefinimos nuestro step, que podemos verlo como nuestra función que involucrará la ejecución de una acción y que en el futuro podemos concatenar a otro step y así sucesivamente. En este caso concreto, simplemente vamos a pintar por consola unechoy la versión de Node y npm porque, como ya hemos mencionado antes, partimos de una imagen global de Node por lo que ya vienen instalados.
Aunque aquí definimos la imagen de manera global, cada step puede declarar su propia image y sobrescribir la del nivel superior.
Voy a verlo con un caso concreto. Imaginemos que casi todo el repo es Node, pero justamente tenemos un script puntual en Python (por ejemplo, para procesar unos datos). El pipeline corre con node:22-alpine de manera global, salvo ese step, que declara su propia imagen:
image: node:22-alpine
pipelines:
default:
- step:
name: Instala y testea la API
script:
- npm ci
- npm test
- step:
name: Procesa los datos con Python
image: python:3.13
script:
- python scripts/procesa.pyDe este modo, ese step arranca sobre python:3.13 mientras el resto del pipeline sigue usando la imagen global de Node.
Entorno real
Escenario baseline
Una vez vista una aproximación a un "hola mundo", voy a ejemplificar un escenario más cercano a la realidad, donde tenemos una API y un frontend. En el frontend utilizamos Angular con Vitest y para la API usaremos Express con Vitest también; para ambos disponemos de un cierto coverage de tests ya implementados. Pues ahora necesitamos ejecutarlos y para ello usaremos el mismo pipeline default que antes, pero esta vez con steps concatenados que se ejecutarán de manera secuencial.
image: node:20-alpine
definitions:
caches:
api-node-modules: api/node_modules
frontend-node-modules: frontend/node_modules
pipelines:
default:
- step:
name: Instala y testea la API
caches:
- api-node-modules
script:
- cd api
- npm ci
- npm test
- step:
name: Instala y testea el frontend
caches:
- frontend-node-modules
script:
- cd frontend
- npm ci
- npm test
- step:
name: Compila la API
caches:
- api-node-modules
script:
- cd api
- npm ci
- npm run build
- step:
name: Compila el frontend
caches:
- frontend-node-modules
script:
- cd frontend
- npm ci
- npm run buildRecordemos que en el YAML hemos podido usar npm ya que usamos una imagen de node:20-alpine de manera global.
Con este pipeline, cada vez que hacemos un push en cualquier rama se ejecutan cuatro steps secuenciales. En cada paso se instalan las dependencias desde cero; los dos primeros ejecutan las pruebas con npm run test y los dos últimos compilan la API y el frontend con npm run build.
Como podemos apreciar, estamos instalando dependencias en cada step. Esto no es lo más eficiente, pero estamos en un ejemplo. Para mejorar la eficiencia de esto, usamos la figura definitions > caches y cacheamos los node_modules que genera la instalación de dependencias tanto de la API como del frontend. Esto hará que las posteriores veces que hagamos npm ci sea más rápido. Es interesante destacar que las cachés son persistentes entre las diferentes ejecuciones de pipelines y tenemos la posibilidad de limpiarlas tanto en la lógica del pipeline como desde la UI de Bitbucket (sección pipelines). Para que un step pueda usar una cache, simplemente usamos la entidad caches dentro del step.
# ...
definitions:
caches:
api-node-modules: api/node_modules
frontend-node-modules: frontend/node_modules
pipelines:
default:
# ...
- step:
name: Instala y testea la API
caches:
- api-node-modules
script:
- cd api
- npm ci
- npm test
# ...Ejecución en paralelo
Analizando el caso anterior, no es difícil apreciar que la responsabilidad de que falle el frontend (dejando a un lado que los tests puedan ser de integración y suponiendo que son simplemente unitarios) no implica nada del lado del servidor. Digamos que los contextos que vemos reflejados en el pipeline secuencial (step1 -> step2 -> step3 -> ...) se solapan. Para esto tenemos otra figura interesante que agrupa la ejecución de nuestros pipelines en pequeñas secuencias de steps, pero ejecutando estas agrupaciones de manera paralela.
image: node:20-alpine
definitions:
caches:
api-node-modules: api/node_modules
frontend-node-modules: frontend/node_modules
pipelines:
default:
- parallel:
- step:
name: Instala y testea la API
caches:
- api-node-modules
script:
- cd api
- npm ci
- npm test
- step:
name: Instala, revisa tipos y testea el frontend
caches:
- frontend-node-modules
script:
- cd frontend
- npm ci
- npm run types:check
- npm test
- parallel:
- step:
name: Compila la API
caches:
- api-node-modules
script:
- cd api
- npm ci
- npm run build
- step:
name: Compila el frontend
caches:
- frontend-node-modules
script:
- cd frontend
- npm ci
- npm run buildPersistencia de nuevo contenido
A menudo, muchas ejecuciones pueden resultar en la generación de nuevo contenido. Cuando hacemos una build, podemos recuperar lo que genera este step usando la propiedad artifacts dentro de los parámetros del step, o el resumen del coverage de los tests, como en el siguiente caso:
# ...
step:
name: Instala y testea la API
caches:
- api-node-modules
script:
- cd api
- npm ci
- npm test
artifacts:
- api/coverage/**
# ...Este resultado estará disponible en la sección de Artifacts dentro de la UI del pipeline y se descargará como un archivo comprimido.
Tipos de condiciones de arranque
He repasado de manera diagonal cómo ejecutar de manera secuencial y paralela los steps que conforman lo que denominamos pipelines. He compartido la caché para usar node_modules y usamos una imagen de Docker base como entorno de ejecución de manera global. Pero siempre lo he aplicado a cualquier evento de push en cualquier rama (default) y esto, aunque interesante para algunos casos de uso, para la mayoría de intenciones no es así. A menudo nos hace falta personalizar diferentes pipelines según sobre qué rama se incorpora código o si una solicitud de incorporación (pull request) ha sido creada. Todas estas nuevas condiciones de arranque las definimos dentro de la entidad pipelines de nuestro YAML.
branches
El primer grupo es branches, que dispara según la rama exacta o el patrón sobre el que se incorpora código. Aquí es importante entender la diferencia entre * y **. El primero cubre un único segmento, mientras que el segundo cruza varios /.
pipelines:
branches:
main:
- step:
name: Se ejecuta al hacer push a main
script:
- echo "Arranque: push a la rama exacta 'main'"
'release/*':
- step:
name: Se ejecuta en cualquier rama release
script:
- echo "Arranque: cualquier rama release/x.y.z (un solo segmento tras release/)"
'feature/**':
- step:
name: Se ejecuta en cualquier rama feature y sus subramas
script:
- echo "Arranque: feature/x o feature/x/y (** cruza multiples '/')"pull-requests
A continuación tenemos pull-requests, que en lugar de reaccionar al push reacciona a la apertura de una pull request (o a un nuevo push sobre su rama origen si ya tenemos una PR en curso). El patrón raíz filtra por la rama origen de la PR, pero podemos afinar todavía más indicando la rama destino anidándola debajo, como en el caso de bugfix/* hacia main.
pipelines:
pull-requests:
'**':
- step:
name: Se ejecuta en cualquier PR
script:
- echo "Arranque: abrir PR o nuevo push a la rama origen de la PR"
'bugfix/*':
main:
- step:
name: Se ejecuta en PRs de bugfix hacia main
script:
- echo "Arranque: PR con origen bugfix/* Y destino exactamente 'main'"merge-queues (añadido recientemente)
Muy relacionado con lo anterior, si trabajamos con una merge queue (esa cola que serializa la integración de varias PRs para evitar que se pisen entre ellas), Bitbucket expone el selector merge-queues. Cuando una PR entra en la cola Bitbucket ejecuta contra él el pipeline que definamos aquí.
pipelines:
merge-queues:
mergequeue-main:
- step:
name: Valida el commit de merge antes de integrar
script:
- echo "Arranque: PR añadida a la merge queue 'mergequeue-main'"La clave interna (mergequeue-main en el ejemplo) no es libre. Tiene que coincidir con el nombre de la merge queue que hayamos configurado en la UI.
tags
El grupo tags reacciona al push de una etiqueta, un patrón muy cómodo para disparar releases cuando etiquetamos una versión (v1.2.3, v2.0.0-rc1, etc.).
pipelines:
tags:
'v*':
- step:
name: Se ejecuta al etiquetar una versión
script:
- echo "Arranque: push de tag v1.2.3, v2.0.0-rc1, etc"custom
Por último, custom define pipelines que no arrancan solos con un evento de Git, sino que los lanzamos nosotros. Un manual-deploy se ejecuta a demanda desde la UI (Run pipeline > Custom), mientras que un nightly-build está pensado para programarse en un Schedule (Repository settings > Pipelines > Schedules), donde se referencia el pipeline y se le asocia una expresión cron.
pipelines:
custom:
manual-deploy:
- step:
name: Se lanza a mano desde la UI
script:
- echo "Arranque: Run pipeline > Custom > manual-deploy (UI)"
nightly-build:
- step:
name: Se ejecuta cada noche por horario programado
script:
- echo "Arranque: Repository settings > Pipelines > Schedules -> referencia este pipeline + cron"Ojo, que manual-deploy y nightly-build no son palabras reservadas sino nombres que elegimos nosotros. A diferencia de branches, pull-requests, tags o el propio custom (que sí son entidades del esquema), aquí cada nombre es un identificador con el que luego referenciamos el pipeline desde la UI.
Despliegues con deployment
Ya adelantaba que Bitbucket permite modelar nuestros entornos (los típicos test, staging y production). Para marcar que un step despliega a uno de ellos, le añadimos la propiedad deployment con el nombre del entorno. Esto no cambia lo que ejecuta el step, pero sí hace que Bitbucket alimente su panel de Deployments, donde llevamos el histórico de qué se ha desplegado en cada entorno y cuándo.
Retomo el escenario de la API. Sobre la rama main compilamos y luego desplegamos a staging de manera automática y a production solo cuando nosotros le demos al botón. Ese "botón" lo conseguimos con trigger: manual, que deja el step esperando una acción manual en lugar de encadenarse solo.
pipelines:
branches:
main:
- step:
name: Compila la API
script:
- cd api
- npm ci
- npm run build
artifacts:
- api/dist/**
- step:
name: Despliega a staging
deployment: staging
script:
- echo "Desplegando a staging contra $API_URL"
- step:
name: Despliega a producción
deployment: production
trigger: manual
script:
- echo "Desplegando a producción contra $API_URL"Me fijo en $API_URL, que es la misma variable en los dos steps pero puede valer distinto en cada entorno porque la definimos como variable de despliegue (más sobre esto en el siguiente apartado). Un step solo puede apuntar a un entorno, y podemos tener varios entornos de cada tipo si los nombramos en la UI.
Variables y secrets
Hasta ahora nos hemos limitado a pintar cosas por consola, pero un pipeline de verdad necesita secretos (tokens, credenciales, ...) y parámetros. Bitbucket los gestiona como variables de entorno que referenciamos con $VARIABLE dentro de nuestros scripts.
Podemos definirlas en tres niveles, de lo más general a lo más concreto. El primero es el workspace (compartidas por todos los repositorios), después está el repositorio y, por último, el entorno de despliegue (las que solo tienen sentido en un staging o un production). Cuando una misma variable existe en varios sitios, el orden de precedencia que gana es Pipeline > Deployment > Repository > Workspace > Default. Además, cualquiera de ellas puede marcarse como secured. Al hacerlo se enmascara en los logs y ya no podremos volver a leerla desde la UI, solo sobrescribirla.
Todas estas variables se configuran desde la UI (Repository settings > Repository variables, o la sección de Deployments para las de entorno). Recordemos que las de despliegue únicamente están disponibles en steps que declaran deployment:.
Un caso muy habitual es el que ya adelantaba al principio, pasarle un parámetro al pipeline en el momento de lanzarlo, como el típico número de versión. Eso lo conseguimos declarando variables en un pipeline custom, y Bitbucket nos pedirá su valor en la UI antes de ejecutar.
pipelines:
custom:
deploy-produccion:
- variables:
- name: VERSION
- step:
name: Despliega la versión indicada
script:
- echo "Desplegando la versión $VERSION"