La integración continua (CI) es una de las prácticas más importantes que trajo consigo la revolución DevOps y su esfuerzo por reducir los ciclos de retroalimentación cuando desarrollamos software.

Para esto los pipelines con verificaciones de calidad y conformidad ejecutados por un servidor de integración continua se volvieron una parte fundamental. Al permitirnos verificar, al menos hasta cierto punto que los cambios que estamos integrando cumplen con los criterios establecidos.

Elixir tiene lo propio y hoy en día contamos con diversas herramientas que nos permiten automatizar diversos aspectos de nuestro código, ponerlos en un pipeline y ejecutarlos automáticamente en cada confirmación.

Por otro lado, me gustaría aprovechar la oportunidad para poner todo este pipeline en un solo script, de forma que podamos utilizarlo fácilmente tanto en desarrollo como en el servidor de CI.

Lo primero es configurar la opción preferred_envs en nuestro archivo mix.exs para no tener que incluir MIX_ENV=test en cada tarea.

def cli do
  [
    preferred_envs: [ci: :test]
  ]
end

Ademas de incluir un alias en el mismo archivo mix.exs para ejecutar nuestra tarea mediante mix ci.

defp aliases do
  [
    # ... otros aliases
    ci: ["CI"]
  ]
end

Ahora creamos nuestro archivo lib/mix/tasks/ci.ex y comenzamos a escribir nuestra tarea.

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  @impl Mix.Task
  def run(_args) do
   ...
  end
end

Verificaciones de las dependencias

El primer paso de nuestro pipeline es obtener las dependencias.

Podemos verificar nuestro archivo mix.exs por dependencias que han sido retiradas utilizando la tarea mix hex.audit, de forma que podamos reemplazarlas.

Despues, por defecto, contamos con la tarea deps.unlock --check-unused que verifica que no incluyamos dependencias en nuestro archivo mix.exs que no estén siendo utilizadas.

Además, la dependencia mix_audit nos permite escanear las dependencias en busca de vulnerabilidades reportadas con la tarea mix deps.audit.

De forma que podemos incluirlas en nuestro archivo de CI:

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  @impl Mix.Task
  def run(_args) do
    Mix.Task.run("deps.get")

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Mix.Task.run("deps.audit", ["--format", "human"])
  end
end

Verificaciones del compilador

Elixir incluye un argumento muy útil llamado --warnings-as-errors. Lo que nos permite tratar las advertencias del compilador como errores, por lo que no nos permitirá avanzar hasta que sean resueltas.

Aunque personalmente me gusta activar esta opción dentro del archivo mix.exs, haciendo que esto funcione todo el tiempo. Algunas personas consideran esta opción algo intrusiva1, ya que suele crear fricción durante el desarrollo al detener el flujo, por ejemplo por olvidar un _ en una variable que no usamos en ese momento.

Aunque existe una incomodidad inicial, encuentro que esta fricción desaparece después de un corto periodo de adaptación, volviéndonos más disciplinados al escribir código. Sin embargo, considero indispensable que esta opción esté activa (al menos) en el servidor de CI.

De otra forma, nuestra consola estará plagada de advertencias que dificultan la lectura de lo que es realmente importante.

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  @impl Mix.Task
  def run(_args) do
    Mix.Task.run("deps.get")

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Mix.Task.run("deps.audit", ["--format", "human"])

    Mix.Task.run("compile", ["--warnings-as-errors"])
  end
end

Verificaciones de formato

Elixir cuenta con una herramienta mix format que nos permite formatear nuestro código de forma automática. Sin embargo, no es raro que se nos olvide formatear nuestro código antes de confirmar, por lo que podemos incluirlo en nuestro script.

Por otro lado, también podemos incluir Credo, una herramienta de análisis estático que nos permite verificar la calidad de nuestro código. Cuenta con muchas reglas que podemos configurar o desactivar, incluso podemos crear nuestras propias reglas. Aunque no todas vienen activadas por defecto, por lo que vale mucho la pena revisar al documentación a fondo.

Ademas credo cuanta con un modo estricto que podemos activar con la opción --strict que nos permite ver todas las reglas sin importar su prioridad.

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  @impl Mix.Task
  def run(_args) do
    Mix.Task.run("deps.get")

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Mix.Task.run("deps.audit", ["--format", "human"])

    Mix.Task.run("compile", ["--warnings-as-errors"])

    Mix.Task.run("format", ["--check-formatted"])

    Mix.Task.run("credo", ["--strict"])
  end
end

Verificaciones de tipado

Elixir es un lenguaje de tipado dinámico, por lo que si bien tenemos opciones como las anotaciones de tipos y las especificaciones de tipos, estas no son verificadas por el compilador. Sin embargo, podemos utilizar dialyxir o alguna de sus alternativas2 para verificar el tipado de nuestro código.

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  @impl Mix.Task
  def run(_args) do
    Mix.Task.run("deps.get")

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Mix.Task.run("deps.audit", ["--format", "human"])

    Mix.Task.run("compile", ["--warnings-as-errors"]

    Mix.Task.run("format", ["--check-formatted"])

    Mix.Task.run("credo", ["--strict"])

    Mix.Task.run("dialyzer")
  end
end

Verificaciones de migraciones

La biblioteca excellent_migrations nos permite revisar las migraciones en busca de operaciones que puedan ser problemáticas. Creo que es una herramienta muy útil para evitar problemas en producción.

Otra verificación útil que podemos agregar es que las migraciones sean reversibles. Como menciona Saša Jurić3

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  @impl Mix.Task
  def run(_args) do
    Mix.Task.run("deps.get")

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Mix.Task.run("deps.audit", ["--format", "human"])

    Mix.Task.run("compile", ["--warnings-as-errors"])

    Mix.Task.run("format", ["--check-formatted"])

    Mix.Task.run("credo", ["--strict"])

    Mix.Task.run("dialyzer")

    Mix.Task.run("ecto.create")

    Mix.Task.run("excellent_migrations.migrate")

    Mix.Task.run("ecto.rollback", ["--all"])
  end
end

Verificaciones de pruebas

Después de las verificaciones anteriores, ahora podemos ejecutar nuestras pruebas. Adicionalmente, podemos incluir la opción --cover para verificar la cobertura de nuestras pruebas y la opción --warnings-as-errors para tratar las advertencias como errores.

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """
  use Mix.Task

  @preferred_cli_env :test

  require Logger

  @impl Mix.Task
  def run(_args) do
    Mix.Task.run("deps.get")

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Mix.Task.run("deps.audit", ["--format", "human"])

    Mix.Task.run("compile", ["--warnings-as-errors"])

    Mix.Task.run("format", ["--check-formatted"])

    Mix.Task.run("credo", ["--strict"])

    Mix.Task.run("dialyzer")

    Mix.Task.run("ecto.create")

    Mix.Task.run("excellent_migrations.migrate")

    Mix.Task.run("ecto.rollback", ["--all"])

    Mix.Task.run("test", ["--cover", "--warnings-as-errors"])
  end
end

También podemos incluir algunos mensajes para proveer algo de información sobre lo que está sucediendo.

defmodule Mix.Tasks.CI do
  @moduledoc """
  Run all the checks and tests for the project
  """

  @preferred_cli_env :test

  use Mix.Task

  require Logger

  @impl Mix.Task
  def run(_args) do
    Logger.info("Getting dependencies", ansi_color: :cyan)

    Mix.Task.run("deps.get")

     Logger.info("Checking for retired dependencies", ansi_color: :cyan)

    Mix.Task.run("cmd", ["MIX_ENV=dev", "mix hex.audit"])

    Logger.info("Checking unused dependencies", ansi_color: :cyan)

    Mix.Task.run("deps.unlock", ["--check-unused"])

    Logger.info("Checking for reported vulnerabilities", ansi_color: :cyan)

    Mix.Task.run("deps.audit", ["--format", "human"])

    Logger.info("Compiling code", ansi_color: :cyan)

    Mix.Task.run("compile", ["--warnings-as-errors"])

    Logger.info("checking formatting", ansi_color: :cyan)

    Mix.Task.run("format", ["--check-formatted"])

    Logger.info("Running credo", ansi_color: :cyan)

    Mix.Task.run("credo", ["--strict"])

    Logger.info("Running dialyzer", ansi_color: :cyan)
    
    Mix.Task.run("dialyzer")

    Logger.info("Creating test database", ansi_color: :cyan)

    Mix.Task.run("ecto.create")

    Logger.info("Checking for migrations", ansi_color: :cyan)

    Mix.Task.run("excellent_migrations.migrate")

    Logger.info("Checking for reversibility of migrations", ansi_color: :cyan)

    Mix.Task.run("ecto.rollback", ["--all"])

    Logger.info("Running tests", ansi_color: :cyan)

    Mix.Task.run("test", ["--cover", "--warnings-as-errors"])
end

Esta tarea es una referencia, dependiendo del proyecto o el equipo podemos agregar o eliminar verificaciones. Otro aspecto es que probablemente llegue un punto en el que ejecutar todo el pipeline en local sea demasiado lento, por lo que podemos crear una tarea destinada a ser ejecutada en local con las verificaciones más importantes y dejar el pipeline completo para el servidor de CI.


  1. Elixir Warnings as Errors…Sometimes ↩︎

  2. Adding Dialyzer without the Pain ↩︎

  3. Towards Maintainable Elixir: The Development Process ↩︎