Releases desde CI sin disparar tu propio pipeline
Un job de release que hace push del commit de versiones dispara un pipeline nuevo, y dos merges seguidos compiten entre sí por publicar en npm. Cómo serializar releases con resource_group, autenticar el push desde un job, y cuándo usar [skip ci] frente a -o ci.skip.
- gitlab
- ci-cd
- devops
La parte 9 dejó al monorepo construyendo solo lo que cambió. Lo que sigue es publicar: cuando un cambio en un paquete compartido llega a la rama principal, ese paquete necesita una versión nueva en npm. El diseño obvio es un job en la rama principal que sube las versiones, publica, y hace push del commit de bump de vuelta al repo.
Ese push es justo el problema. Un push a la rama principal es exactamente lo que dispara el pipeline, así que el job de release termina agendando una corrida completa cuyo único propósito es procesar un commit que el propio pipeline creó. Si la lógica de versionado no es perfectamente idempotente, esa segunda corrida puede volver a publicar. Y hay una segunda forma de romperse que no tiene nada que ver con el bucle: dos MRs mergeados con pocos minutos de diferencia generan dos pipelines, los dos llegan al job de release, y los dos intentan versionar y hacer push. Uno pierde la carrera y falla en el git push, o peor, publican los dos.
El flujo de release en un solo job
Nosotros usamos Changesets, y los detalles de la herramienta pueden quedar en segundo plano porque la mecánica de CI es la misma para cualquier herramienta que versiona a partir de metadata versionada en el repo. Cada MR que toca un paquete publicable incluye un changeset, un archivo markdown pequeño que declara qué paquetes cambiaron y si el cambio es patch, minor o major. En la rama principal, un job consume esos archivos, reescribe versiones y changelogs, publica, y empuja el commit resultante junto con sus tags:
release:
stage: release
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- pnpm changeset version
- git commit -am "chore: release packages"
- pnpm changeset publish
- git push --follow-tags origin "HEAD:$CI_DEFAULT_BRANCH"La línea de rules es la misma compuerta de la parte 5. Sin ella, cada rama de feature intentaría hacer su propio release.
Tal como está escrito, este job carga con los tres problemas del inicio: nada impide que dos pipelines lo corran a la vez, el runner no tiene credenciales para hacer push, y si el push llega a funcionar, dispara el siguiente pipeline. El resto del post los va resolviendo de a uno.
Un release a la vez
resource_group apareció brevemente en la parte 6, evitando que dos jobs de deploy tocaran el mismo ambiente al mismo tiempo. Los releases necesitan exactamente el mismo tratamiento. Los jobs que comparten un valor de resource_group corren de a uno, y la serialización aplica entre pipelines distintos, no solo dentro del mismo:
release:
resource_group: npm-releaseCon eso, dos merges seguidos hacen fila en lugar de competir: el job de release del segundo pipeline espera a que termine el del primero. Por defecto la fila se procesa en modo unordered; para releases conviene cambiar el grupo a oldest_first (se configura vía API sobre el resource group) para que las publicaciones salgan en el orden en que se crearon los pipelines.
Lo que resource_group no hace es reconciliar la historia de git. El segundo job hizo checkout de su propio commit, y para cuando le toca correr, el primero ya movió la punta de la rama. Su push sería rechazado como non-fast-forward, así que el script necesita un git pull --rebase antes de empujar, y el paso de versionado tiene que ejecutarse después de ese pull, no antes.
Autenticar el push
El runner clona el repo con un job token efímero que, por defecto, no puede hacer push. GitLab tiene un setting de proyecto que permite push con el job token (viene apagado, y la documentación advierte sobre habilitarlo en proyectos que funcionan como mirror), pero el camino estándar es un project access token: se crea con el scope write_repository, se guarda como variable de CI/CD marcada como masked y protected, y el job reescribe el remote antes de empujar:
release:
script:
- git remote set-url origin
"https://ci-release:${RELEASE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
# ...versionar, publicar y hacer push como antesEl nombre de usuario puede ser cualquier valor no vacío; el token va como password. Masked evita que aparezca en los logs del job. Protected significa que solo los pipelines de ramas protegidas reciben la variable, lo cual se complementa con la compuerta de rules: si un pipeline de feature llegara a correr este job, encontraría RELEASE_TOKEN vacío.
Publicar en npm requiere su propio token como segunda variable masked, pero ese es apenas una variable de entorno que la herramienta lee. Las decisiones específicas de GitLab viven del lado de git.
No disparar el siguiente pipeline
GitLab se salta el pipeline de cualquier commit cuyo mensaje contenga [skip ci] o [ci skip], con cualquier combinación de mayúsculas. El arreglo más barato es entonces una cadena en el mensaje del commit de bump:
git commit -am "chore: release packages [skip ci]"La alternativa es la push option, git push -o ci.skip (Git 2.10 o superior), que omite el pipeline de ese push sin tocar el mensaje del commit. Dos diferencias deciden entre una y otra. El marcador en el mensaje viaja con el commit para siempre: el salto queda documentado en la historia y no depende de cómo llegue el commit al servidor. La push option mantiene los mensajes limpios, pero es una decisión que se toma en cada push y, según la documentación, no omite los merge request pipelines, algo que importa si tu flujo de release empuja los commits de bump a una rama con un MR abierto (el patrón del MR de «version packages») en lugar de ir directo a la rama principal.
Para un bot que empuja directo a la rama principal, las dos funcionan; nosotros usamos [skip ci] porque la evidencia de por qué no corrió un pipeline queda a la vista en el log de commits. Y si habilitaste el push con job token de la sección anterior, ten en cuenta que esos pushes no disparan ningún pipeline, sin necesidad de marcador.
De dónde salió el bug de regex de la parte 5
En la parte 5 conté la historia de una regla con $CI_COMMIT_MESSAGE =~ /[skip ci]/ que silenciosamente hacía match con casi cualquier commit, porque los corchetes sin escapar forman una clase de caracteres: el patrón matchea cualquiera de esas letras sueltas. Este job de release es donde nació esa regla. Antes de confiar en el manejo nativo de GitLab, alguien agregó una regla de workflow para filtrar los pipelines de los commits de bump comparando el mensaje, y la clase de caracteres hizo que casi todos los pushes se saltaran el CI. En su momento el arreglo fue escapar los corchetes; el arreglo de verdad fue borrar la regla, porque GitLab ya se salta los commits con [skip ci] por su cuenta.
Dale al bot su propio nombre
Un commit necesita un committer, así que el job tiene que configurar user.name y user.email. Nosotros tuvimos un correo personal hardcodeado ahí durante más de un año. Cada release del historial quedó atribuido a un solo ingeniero, su nombre aparecía en commits de changelog de paquetes que jamás tocó, y el día que dejó la empresa, esa identidad se convirtió en un pequeño problema de arqueología: un autor al que ya nadie podía preguntarle nada, pegado a una automatización de la que todos dependían.
El project access token de la sección anterior resuelve esto gratis. Crearlo también crea un usuario bot en el proyecto, con un username generado y un correo noreply, y las contribuciones hechas con el token se atribuyen a ese bot. Configura la identidad del commit para que coincida:
release:
before_script:
- git config user.name "release-bot"
- git config user.email "project_42_bot@noreply.example.com"Cualquier identidad de rol funciona; lo que importa es que ninguna persona en particular sea su dueña. Rotar el token más adelante no reescribe el historial de contribuciones de nadie.
Con qué te quedas
Los merges a la rama principal ahora publican exactamente una vez: serializados por resource_group, empujados por un bot con un token acotado, y marcados para que el commit de bump no despierte al pipeline que lo creó. Con eso se cierra el bucle que abrió la introducción.
También se cierra un arco más grande: todo lo que ha construido esta serie hasta aquí reacciona a un push. Hay señales que no tienen un push al cual reaccionar. La suite de E2E es demasiado lenta e inestable para bloquear merges, pero el equipo igual necesita su veredicto todos los días, y eso significa que algunos pipelines tienen que correr con un reloj en lugar de un commit. La parte 11 trata sobre los scheduled pipelines.