CI/CD usando Bitbucket Pipelines

bitbucket-pipelines

Cómo utilizar Bitbucket Pipelines para automatizar nuestros flujos de CI/CD.

13 min de lectura

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.

Nota

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.

bitbucket-pipelines.yml
image: node:22-alpine
 
pipelines:
  default:
    - step:
        name: Saluda y comprueba versiones
        script:
          - echo "Hola desde Bitbucket Pipelines!"
          - node --version
          - npm --version

Voy 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 pipelines definimos esas condiciones de comienzo para nuestras automatizaciones. En este caso, con el uso de default, 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 default definimos 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 un echo y 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.
Nota

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:

bitbucket-pipelines.yml
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.py

De 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.

bitbucket-pipelines.yml
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 build
Nota

Recordemos 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.

bitbucket-pipelines.yml
# ...
 
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.

bitbucket-pipelines.yml
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 build

Persistencia 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:

bitbucket-pipelines.yml
# ...
 
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 /.

bitbucket-pipelines.yml
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.

bitbucket-pipelines.yml
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í.

bitbucket-pipelines.yml
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'"
Nota

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.).

bitbucket-pipelines.yml
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.

bitbucket-pipelines.yml
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"
Nota

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.

bitbucket-pipelines.yml
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.

bitbucket-pipelines.yml
pipelines:
  custom:
    deploy-produccion:
      - variables:
          - name: VERSION
      - step:
          name: Despliega la versión indicada
          script:
            - echo "Desplegando la versión $VERSION"
© 2026Roberto Pérez Llanos