Linux

Sistemas distribuidos y equipos remotos, ¿es buena idea usar un monorepo?

Soy un convencido de que no existen ni habrán silver bullets para soluciones parecidas en tecnología. No hay caminos correctos, solo decisiones adecuadas para contextos determinados.

Hace ya casi un año tomé la decisión de unirme al equipo de tecnología de Shinkansen. Me convenció que en este lugar existe el espacio para iterar rápido, promover y llevar a cabo cosas. Acá quiero contar uno de los tantos desafíos en los que me ha tocado participar.

Contexto

Cuando llegué a Shinkansen éramos un equipo técnico de menos de 10 personas con un sistema distribuido de n apps que crecía cada vez más y los repo incrementaban por mes. Lo feo: todo repartido en varios repositorios de Git.

Funcionaba y nos permitía iterar rápido, pero no siempre como queríamos. El costo de mantención, los cambios de contexto y los parches dolían a la hora de mantener todo actualizado. Si querías meter un cambio con dependencia en otro sistema, eran pull requests cruzados. Eso no escalaba a la velocidad que buscábamos.

Lo interesante es que a esa fecha teníamos alrededor de 70 repositorios y aun así, siendo menos de 10 personas, los manteníamos. Y ojo: la decisión de llevar esto a un monorepo fue antes de la ola de IA.

El punto de partida

El objetivo era un repositorio transversal para toda el área de tecnología. No lo logramos de la noche a la mañana: creció de forma orgánica, iterando, observando, corrigiendo y sumando.

La primera decisión fue mover el primer repo. Obvio, ¿no? No tan fácil, mi joven saltamontes: no se trata de mover un repo, sino de pensar en todos los que vendrán después.

Por eso construimos un script que migra un repo, mantiene toda la historia y entra como un Pull Request. Eso nos dio dos cosas: el tracking de cuándo se migró y las referencias intactas en los blames, que son metadata útil para entender el código.

El siguiente desafío fue el CI. Generamos un composite action que aplica un diff para ver qué directorios se tocan y corre la batería de tests de cada uno. Ese action emite un resultado que usamos como validador de finalización y es un paso requerido para mergear.

Esto se vería algo como:

name: CI Test

on:
  pull_request:
    branches:
      - main

  push:
    branches:
      - main

jobs:
  check-test-project:
    name: Check projects changes
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.detect.outputs.matrix }}
    steps:
      - uses: ./.github/actions/detect-changes
        id: detect

  run-test:
    needs: check-test-project
    if: needs.check-test-project.outputs.matrix != '[]'
    runs-on: ubuntu-latest

    # Use matrix for project changes
    strategy:
      matrix:
        project: ${{ fromJson(needs.check-test-project.outputs.matrix) }}

    env:
      MIX_ENV: test

    defaults:
      run:
        working-directory: ${{ matrix.project }}

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
      - name: Set up and run test...


  validate-run-test:
    name: Validate test are OK
    needs: run-test
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Check run-test result
        run: |
          echo "needs.run-test.result: ${{ needs.run-test.result }}"
          echo "needs.run-test.outputs (if any):"
          echo "${{ toJSON(needs.run-test.outputs) }}"

          if [[ "${{ needs.run-test.result }}" == "failure" || "${{ needs.run-test.result }}" == "cancelled" ]]; then
            echo "Some matrix jobs failed or were cancelled"
            exit 1
          fi
          echo "All matrix test jobs passed"

Las releases

El siguiente problema: cómo enganchar los builds y hacer releases.

Nuestra primera solución fue mala: un archivo VERSION por directorio que, al cambiar, disparaba el build, pusheaba la imagen y generaba el release a los ambientes, dejando a cada ingeniero decidir cuándo deployar. Sonaba bien al inicio, pero el sistema de releases funciona mejor como metadata afuera del código. Lo corregimos usando tags con el formato app-scope@x.y.z, con esto el sistema reconoce a que tiene que hacer release.

Para automatizarlo construimos el releaser, un CLI que le da a cada ingeniero independencia y control sobre sus releases. Antes de eso teníamos que normalizar los commits, así que agregamos reglas en CI y un git hook que validan cómo los escribimos, siguiendo Conventional Commits.

Con los commits formateados al estilo que queríamos, el releaser arma el changelog al vuelo agrupando todo lo que hay entre el último release del componente y el nuevo en base a un git log como este

git -C app log app@old-version..app@new-version --format="%s"

El resultado queda así:

## Changes

### Features

* add multi-currency support to payout batches ([#4120](https://github.com/acme/widgets/pull/4120)) ([3f9c1ab](https://github.com/acme/widgets/commit/3f9c1ab))
* expose webhook retry history in the dashboard ([#4118](https://github.com/acme/widgets/pull/4118)) ([b72d40e](https://github.com/acme/widgets/commit/b72d40e))
* allow scheduling reports in the user's local timezone ([#4111](https://github.com/acme/widgets/pull/4111)) ([c05e8f2](https://github.com/acme/widgets/commit/c05e8f2))

### Bug Fixes

* prevent duplicate notifications on concurrent status updates ([#4123](https://github.com/acme/widgets/pull/4123)) ([9a1b6d7](https://github.com/acme/widgets/commit/9a1b6d7))
* correct rounding error in fee calculation for small amounts ([#4117](https://github.com/acme/widgets/pull/4117)) ([e48f302](https://github.com/acme/widgets/commit/e48f302))
* handle empty response body when the provider times out ([#4109](https://github.com/acme/widgets/pull/4109)) ([1d7c95b](https://github.com/acme/widgets/commit/1d7c95b))

Y eso enlazado al deploy.

Con esto ya éramos full funcionales, pero siempre hay algo que mejorar. Con todos los proyectos migrados, la pregunta pasó a ser cómo asegurar y optimizar el proceso de entrega y deploy. Lo resolvimos con dos componentes: merge-gate y merge-queue.

Merge gate

El merge-gate supervisa que los cambios a introducir no colisionen por directorio, y es un check requerido para mergear: si el diff del pull request contra main no toca los mismos archivos, pasa. Cuando llegan cambios a main, un chequeo busca los otros PR abiertos sobre esos directorios, los marca como overlapping y les deja el aviso:

"Main has new commits in the same directories as this PR."
"Please rebase or merge main into your branch."

Desde ahí cada owner puede hacer update branch con un botón en GitHub, pero para mejorar eso está el siguiente componente.

La queue de Pull Request

El merge-queue es una cola de mergeo: un PR entra, y si tiene los approvals suficientes y pasa los chequeos, se mergea automáticamente. Si detecta un PR con un directorio desfasado, hace el update solo. Además notifica a owners y revisores en Slack cuando un PR entra a la cola, cuando hay conflictos y cuando pasa cierto threshold de espera.

Con los proyectos migrados y la adopción de LLMs, agregamos skills para crear commits, crear PRs, hacer releases y revisiones, correr chequeos por equipo y muchos otros. El foco siempre fue el mismo: el skill es un wrapper de un CLI, porque ejecutar comandos nos da más determinismo que dejárselo al modelo. Todo eso vive en ai-assistance.

Como arista final fue unificar el setup local. Con un comando cada equipo levanta todo el ambiente de desarrollo:

./shinkansen setup, start, stop, etc...

El equipo no es tan grande -somos 17 personas que codeamos activamente- y tener todo concentrado en un solo lugar nos ayuda a trabajar con mejor contexto y apoyados en las herramientas de LLMs.

Al final, el árbol de código es algo como esto:

├── AGENTS.md
├── ai-assistance
├── .ai-configured
├── architecture
├── base-images
├── CLAUDE.md
├── commons
├── core
├── dashboard
├── app-n
├── app-n
├── dependefender
├── deploy
├── docs
├── env
├── .github
├── .gitignore
├── operator-n
├── ....
├── README.md
├── releaser
├── scripts
├── secret-manager
├── .shellcheckrc
├── shinkansen
├── shinkansen_audit
├── shinkansen-parser
├── shinkansen_utils
├── simulated-bank
├── treasury
├── ...
└── utils

Esta no es una receta universal: es la nuestra, adaptada a nuestro contexto y a la forma de trabajo que le acomoda a nuestros equipos. Cometimos errores que nos llevaron a mirar, observar y arreglar, y así llegamos a un proceso que hoy nos funciona. Tampoco está terminado, y esa es la gracia: no es tan rígido, nos deja ir agregando cosas según lo que vayamos necesitando.

Lo bueno es que todo fue incremental. Nunca tuvimos que frenar los flujos de trabajo ni sudar frío por un cambio que dejara todo detenido.

Con todo en un solo repositorio tenemos el monitoreo de los componentes unificado en código, sistemas de auto parche y una base ideal para trabajar con LLMs. Sumado a eso, en el repo hacemos bastante scripting en bash que nos deja construir herramientas útiles sin complejidad de más.

Y ahora que lo miro en retrospectiva, terminamos en algo bastante al estilo Unix: un montón de herramientas chicas que hacen una cosa y se comunican por convenciones en texto. Los tags son el contrato entre el releaser y el deploy; el changelog sale de un git log. Nunca lo planteamos así, pero es donde llegamos.

Si tuviera que planificarlo de nuevo desde el inicio, empezaría por normalizar la forma de los commits y los pull requests: es metadata valiosa sobre la que después construís todo lo demás. Y tendría siempre en mente tomar decisiones elásticas en vez de estáticas, como la que nos costó el archivo VERSION.

Nota: Si llegaste hasta aqui puede que interesen los temas que escribo. Puedes seguirme en Twitter o suscribirte a los nuevos artículos

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.