DevOps from Zero to Hero: Tu Primer Pipeline de CI con GitHub Actions
Apoya este blog
Si te resulta util este contenido, considera apoyar el blog.
Introduccion
Bienvenido al quinto articulo de la serie DevOps from Zero to Hero. En el articulo anterior escribimos tests unitarios y de integracion para un proyecto TypeScript. Los tests son geniales, pero solo sirven si alguien los corre. Ese alguien no deberias ser vos, manualmente, justo antes de un deploy. Deberia ser una maquina que los corre cada vez que el codigo cambia.
De eso se trata la Integracion Continua (CI): automatizar las tareas aburridas, repetitivas y criticas para que los humanos puedan enfocarse en escribir codigo. En este articulo vamos a construir un pipeline de CI completo con GitHub Actions desde cero. Al final, cada push y pull request a tu repositorio va a hacer lint del codigo automaticamente, correr tests, construir una imagen Docker y pushearla a un registro de contenedores.
Vamos a meternos de lleno.
Que es CI y por que importa
Integracion Continua es la practica de construir y testear codigo automaticamente cada vez que alguien pushea un cambio. La palabra “continua” es importante: esto no es algo que haces una vez por semana o antes de un release. Pasa en cada commit, cada pull request, cada vez.
Por que importa? Tres razones:
- Atrapar bugs temprano: Un bug encontrado en CI cuesta minutos en arreglar. Un bug encontrado en produccion cuesta horas, confianza de los clientes y a veces plata. Cuanto antes lo atrapes, mas barato es.
- Aplicar estandares: Linting, formateo y type checking no deberian depender de que los desarrolladores se acuerden de correrlos. CI aplica estos estandares automaticamente, cada vez.
- Automatizar tareas repetitivas: Construir imagenes Docker, correr suites de tests, generar artefactos. Estas son cosas que deberia hacer una maquina, no una persona.
Sin CI, tu workflow se ve asi: un desarrollador escribe codigo, se olvida de correr el linter, pushea a main, rompe el build y todo el equipo se entera una hora despues. Con CI, el linter corre automaticamente, el push se bloquea y el desarrollador lo arregla en cinco minutos antes de que afecte a alguien mas.
CI es la primera capa real de automatizacion en un pipeline de DevOps. Todo lo demas, delivery continuo, deployment continuo, infraestructura como codigo, todo se construye arriba de CI.
Fundamentos de GitHub Actions
GitHub Actions es una plataforma de CI/CD integrada en GitHub. Definis workflows como archivos YAML en
un directorio .github/workflows/, y GitHub los ejecuta por vos en maquinas virtuales hosteadas. No
hay un servicio separado que configurar, no hay webhooks que armar y no hay servidores que administrar.
Antes de escribir YAML, entendamos los conceptos clave:
- Workflow: Un archivo YAML que define un proceso automatizado. Cada workflow vive en
.github/workflows/y se dispara por eventos.- Evento (trigger): Lo que causa que el workflow se ejecute. Triggers comunes son
push,pull_requestyschedule.- Job: Un conjunto de pasos que corren en la misma maquina virtual (llamada “runner”). Un workflow puede tener multiples jobs, y por defecto corren en paralelo.
- Step: Una tarea individual dentro de un job. Un step puede ejecutar un comando de shell o usar una action pre-construida.
- Action: Una unidad reutilizable de codigo que realiza una tarea comun. Por ejemplo,
actions/checkout@v4clona tu repositorio, yactions/setup-node@v4instala Node.js.- Runner: La maquina virtual que ejecuta tu job. GitHub provee runners hosteados con Ubuntu, Windows y macOS.
Aca esta la jerarquia visualizada:
Workflow (.github/workflows/ci.yml)
├── Evento: push a main, pull_request
├── Job: lint
│ ├── Step: Checkout codigo
│ ├── Step: Setup Node.js
│ └── Step: Correr ESLint
├── Job: test
│ ├── Step: Checkout codigo
│ ├── Step: Setup Node.js
│ ├── Step: Instalar dependencias
│ └── Step: Correr Vitest
└── Job: build
├── Step: Checkout codigo
├── Step: Setup Docker Buildx
└── Step: Build y push imagen
Triggers: cuando corre CI?
La key on en tu archivo de workflow define cuando se ejecuta. Estos son los triggers que vas a usar
con mas frecuencia:
# Correr en cada push a main
on:
push:
branches: [main]
# Correr en cada pull request apuntando a main
on:
pull_request:
branches: [main]
# Correr en push y pull request
on:
push:
branches: [main]
pull_request:
branches: [main]
# Correr en un schedule (sintaxis cron, todos los dias a las 6 AM UTC)
on:
schedule:
- cron: "0 6 * * *"
# Correr manualmente desde la UI de GitHub
on:
workflow_dispatch:
Para un proyecto tipico, queres que CI corra tanto en push como en pull_request a la rama main.
El trigger de push atrapa cualquier cosa que llegue a main directamente, y el trigger de pull request
te da feedback antes de mergear.
Construyendo el pipeline paso a paso
Vamos a construir un pipeline de CI real para un proyecto TypeScript. Empezamos simple y vamos
agregando funcionalidades de forma incremental. Crea el archivo .github/workflows/ci.yml en tu
repositorio:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npx eslint . --max-warnings 0
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Veamos que esta pasando aca:
actions/checkout@v4: Clona tu repositorio en el runner. Sin esto, el runner no tiene codigo con el que trabajar.actions/setup-node@v4: Instala la version especificada de Node.js y configura el caching de npm.npm ci: Instala las dependencias depackage-lock.jsonexactamente como se especifican. A diferencia denpm install, no modifica el lockfile y es mas rapido y confiable en CI.npx eslint . --max-warnings 0: Corre ESLint y falla si hay algun warning. Esto es mas estricto que el comportamiento por defecto, que solo falla por errores. Tratar los warnings como errores en CI evita que se acumulen.- Los jobs de lint y test corren en paralelo: Como no dependen uno del otro, GitHub los corre al mismo tiempo, haciendo tu pipeline mas rapido.
Agregando el build de Docker
Ahora agreguemos un job que construya una imagen Docker y la pushee a GitHub Container Registry (GHCR).
Este job solo deberia correr despues de que lint y tests pasen, asi que usamos la keyword needs para
crear una dependencia:
build:
name: Build and Push Docker Image
runs-on: ubuntu-latest
needs: [lint, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Hay mucho pasando aca, asi que vamos a desarmarlo:
needs: [lint, test]: Este job espera a que tanto lint como test pasen antes de correr. Si alguno falla, el build se saltea completamente.if: github.event_name == 'push' && github.ref == 'refs/heads/main': Solo construye imagenes en pushes a main, no en pull requests. No queres pushear una imagen Docker por cada PR.permissions: GitHub Actions usa unGITHUB_TOKENque se crea automaticamente para cada ejecucion de workflow. Necesitamospackages: writepara pushear a GHCR.docker/setup-buildx-action@v3: Configura Docker Buildx, que es una herramienta de build extendida que soporta funcionalidades avanzadas como caching y builds multi-plataforma.docker/login-action@v3: Se loguea a GHCR usando elGITHUB_TOKENintegrado. No necesitas crear un token de acceso personal.docker/metadata-action@v5: Genera tags y labels automaticamente. Tagueamos con el SHA de Git (para trazabilidad) ylatest(por conveniencia).docker/build-push-action@v6: Construye el Dockerfile y pushea la imagen. Las lineascache-fromycache-tohabilitan el cache de GitHub Actions para las capas de Docker, que explicamos a continuacion.
Caching: haciendo CI rapido
Los pipelines de CI que tardan 10 minutos se convierten rapidamente en un cuello de botella. Los desarrolladores dejan de esperar los resultados, empiezan a mergear sin verificar y se pierde todo el sentido de CI. El caching es como mantenes las cosas rapidas.
Hay dos cosas que vale la pena cachear en un proyecto Node.js: paquetes npm y capas de Docker.
npm cache es lo mas facil. La action actions/setup-node@v4 lo maneja por vos cuando agregas
cache: "npm":
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
Esto cachea el cache de descarga de npm (no node_modules), asi que npm ci todavia corre pero no
necesita descargar paquetes del registry. La primera ejecucion llena el cache y las siguientes lo
reutilizan. En un proyecto con muchas dependencias, esto puede ahorrar 30 a 60 segundos por ejecucion.
Docker layer cache tiene mas impacto. Construir una imagen Docker desde cero cada vez es un desperdicio porque la mayoria de las capas (como la imagen base y los paquetes del sistema instalados) rara vez cambian. Docker Buildx con el backend de cache de GitHub Actions guarda las capas entre ejecuciones:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha: Trae capas cacheadas del cache de GitHub Actions.cache-to: type=gha,mode=max: Pushea todas las capas al cache despues de construir. La opcionmode=maxcachea las capas intermedias tambien, no solo las capas de la imagen final.
Un Dockerfile bien estructurado se beneficia enormemente de esto. Si tu capa de instalacion de
dependencias no cambio, Docker reutiliza la capa cacheada en vez de correr npm ci de nuevo
dentro del contenedor. Esto puede reducir los tiempos de build de minutos a segundos.
Matrix builds: testeando entre versiones
A veces necesitas testear tu codigo contra multiples versiones de Node.js, o multiples sistemas operativos, o ambos. Los matrix builds te permiten definir un conjunto de variables y correr el job una vez por cada combinacion.
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["20", "22"]
fail-fast: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Esto corre el job de test dos veces: una con Node 20 y otra con Node 22. Ambas ejecuciones pasan en paralelo en runners separados, asi que no ralentiza tu pipeline.
Configuraciones clave:
strategy.matrix: Define las variables y sus valores. Podes agregar mas dimensiones, comoos: [ubuntu-latest, windows-latest], y GitHub va a correr cada combinacion.fail-fast: false: Por defecto, si un job de la matrix falla, GitHub cancela los demas. Poner esto enfalsedeja que todos los jobs terminen, asi podes ver todas las fallas a la vez.
Los matrix builds son especialmente utiles para librerias que necesitan soportar multiples runtimes. Para codigo de aplicacion donde vos controlas el runtime, testear una sola version suele ser suficiente.
Secrets y variables de entorno
Tu pipeline de CI va a necesitar credenciales frecuentemente: API keys para servicios externos, tokens para registries o passwords de bases de datos para tests de integracion. GitHub provee dos mecanismos para esto.
Variables de entorno son para valores no sensibles:
env:
NODE_ENV: test
API_URL: https://api.staging.example.com
steps:
- name: Run tests
run: npm test
env:
DATABASE_URL: postgres://localhost:5432/testdb
Podes definir variables de entorno a nivel de workflow, de job o de step. Las variables a nivel de step sobreescriben las de nivel de job, que sobreescriben las de nivel de workflow.
Secrets son para valores sensibles como API keys y tokens:
- name: Deploy to staging
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Para agregar secrets, anda a Settings de tu repositorio, despues Secrets and variables, despues
Actions. Los secrets se encriptan en reposo y se enmascaran en los logs. GitHub va a reemplazar
el valor del secret con *** si accidentalmente aparece en la salida.
Reglas importantes sobre secrets:
- Nunca hardcodees secrets en tus archivos de workflow. Estan commiteados al repositorio y son visibles para cualquiera con acceso de lectura.
GITHUB_TOKENes automatico. No necesitas crearlo. GitHub genera uno para cada ejecucion de workflow con permisos limitados al repositorio.- Los secrets no estan disponibles en pull requests de forks. Esta es una funcionalidad de seguridad. Si tus tests necesitan secrets, van a fallar en PRs de forks, lo cual es esperado.
- Usa environments para secrets de deployment. Los environments de GitHub te permiten requerir aprobaciones y restringir que ramas pueden usar ciertos secrets.
Workflows reutilizables: manteniendo las cosas DRY
A medida que tu organizacion crece, vas a tener multiples repositorios que necesitan pipelines de CI similares. Copiar y pegar archivos YAML entre repositorios es una pesadilla de mantenimiento. Los workflows reutilizables te permiten definir un workflow una vez y llamarlo desde otros workflows.
Primero, crea el workflow reutilizable en un repositorio compartido. La diferencia clave es el
trigger workflow_call:
# .github/workflows/node-ci.yml (en tu repo compartido)
name: Node.js CI
on:
workflow_call:
inputs:
node-version:
description: "Version de Node.js a usar"
required: false
type: string
default: "22"
run-lint:
description: "Si correr linting o no"
required: false
type: boolean
default: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
if: ${{ inputs.run-lint }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- run: npm ci
- run: npx eslint . --max-warnings 0
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
Despues lo llamas desde cualquier repositorio:
# .github/workflows/ci.yml (en tu repo del proyecto)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: your-org/shared-workflows/.github/workflows/node-ci.yml@main
with:
node-version: "22"
run-lint: true
Los beneficios son significativos:
- Unica fuente de verdad: Actualiza el workflow compartido y cada repositorio que lo usa recibe la actualizacion.
- Consistencia: Cada proyecto sigue el mismo proceso de CI, mismas versiones de actions, misma estrategia de caching.
- Menos mantenimiento: Arregla un bug o actualiza una action en un lugar, no en cincuenta repositorios.
- Los inputs lo hacen flexible: Cada proyecto puede personalizar el comportamiento (version de Node, si hacer lint o no, etc.) sin forkear el workflow.
Status badges: mostra la salud de tu pipeline
Una vez que tu pipeline de CI esta funcionando, queres que todos vean su estado de un vistazo. GitHub provee badges de estado que podes agregar a tu README:

Esto se renderiza como un badge chiquito que muestra “passing” (verde) o “failing” (rojo) basado en la ultima ejecucion del workflow. Agregalo al principio de tu README para que los colaboradores sepan inmediatamente la salud del proyecto.
Tambien podes hacer badges especificos por rama:

Esto solo refleja el estado del workflow en la rama main, ignorando las feature branches.
Branch protection: requerir que CI pase antes de mergear
Un pipeline de CI solo es util si la gente no puede saltearselo. Las reglas de branch protection aseguran que el codigo no se pueda mergear a main a menos que CI pase. Aca te explico como configurarlo:
- Anda a Settings de tu repositorio, despues Branches.
- Clickea “Add branch protection rule” (o “Add classic branch protection rule”).
- Pone el patron de nombre de rama en
main.- Marca “Require status checks to pass before merging.”
- Busca y selecciona los nombres de tus jobs de CI (por ejemplo, “Lint”, “Test”).
- Opcionalmente marca “Require branches to be up to date before merging” para evitar mergear ramas desactualizadas.
Con esto configurado, el boton de merge en un pull request esta deshabilitado hasta que todas las verificaciones requeridas pasen. Nadie puede saltear CI, ni siquiera los admins del repositorio (a menos que lo sobreescriban explicitamente, lo cual deja un registro de auditoria).
Protecciones adicionales que vale la pena habilitar:
- Requerir reviews de pull request: Al menos un miembro del equipo debe aprobar antes de mergear.
- Requerir historia lineal: Forzar squash o rebase merges para una historia de git limpia.
- No permitir saltear las configuraciones anteriores: Incluso los admins deben seguir las reglas.
El archivo de workflow completo
Aca esta el pipeline de CI completo combinando todo lo que cubrimos. Este es un punto de partida listo para produccion para cualquier proyecto TypeScript:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_ENV: test
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npx prettier --check .
- name: Run ESLint
run: npx eslint . --max-warnings 0
- name: Type check
run: npx tsc --noEmit
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["20", "22"]
fail-fast: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage report
if: matrix.node-version == '22'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 14
build:
name: Build and Push Docker Image
runs-on: ubuntu-latest
needs: [lint, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Fijate en algunas cosas de este workflow completo:
- Tres etapas: Lint, test y build. Forman un pipeline donde cada etapa filtra la siguiente.
- Type checking en lint: Agregamos
tsc --noEmitpara atrapar errores de TypeScript. Este es un chequeo barato que atrapa toda una clase de bugs.- Prettier check:
prettier --checkverifica el formato sin modificar archivos. Si un desarrollador se olvido de formatear, CI lo atrapa.- Coverage solo se sube una vez: Cuando corres un matrix build, solo necesitas un reporte de coverage, no uno por version de Node. El condicional
if: matrix.node-version == '22'maneja esto.- Dias de retencion: Los artefactos no necesitan existir para siempre. Poner
retention-days: 14mantiene las cosas ordenadas.- Variables de entorno arriba:
REGISTRYeIMAGE_NAMEse definen una vez y se reutilizan, haciendo que el workflow sea mas facil de adaptar a otros registries.
Debuggeando workflows fallidos
Cuando tu pipeline de CI falle (y va a fallar), aca tenes como debuggearlo:
- Lee los logs: Clickea en el job fallido en la UI de GitHub Actions. Cada step muestra su salida. El error generalmente esta en las ultimas lineas del step fallido.
- Corre localmente primero: Antes de pushear, corre los mismos comandos localmente.
npm ci && npx eslint . && npm testdeberia producir el mismo resultado que CI.- Verifica el entorno del runner: CI corre en una maquina Ubuntu limpia. Si algo funciona localmente pero falla en CI, la diferencia suele estar en variables de entorno, herramientas instaladas o rutas de archivos.
- Usa
actpara testing local: La herramientaact(https://github.com/nektos/act) te permite correr workflows de GitHub Actions en tu maquina local usando Docker. No es perfecto, pero atrapa la mayoria de los problemas.- Habilita el logging de debug: Re-ejecuta el workflow con logging de debug habilitado yendo a la ejecucion fallida, clickeando “Re-run all jobs” y marcando “Enable debug logging.” Esto agrega salida verbosa de cada action.
Errores comunes y como evitarlos
Algunas cosas que complican a la gente cuando configura CI por primera vez:
- No usar
npm ci: Usarnpm installen CI puede producir arboles de dependencias diferentes a tu maquina local. Siempre usanpm ci, que instala exactamente lo que esta enpackage-lock.json.- Falta
package-lock.jsonen el repositorio: Si lo pusiste en el gitignore,npm civa a fallar. El lockfile siempre deberia estar commiteado.- Tests que dependen del orden: Si tus tests pasan localmente pero fallan en CI, podrian depender del orden de ejecucion. Vitest corre tests en paralelo por defecto, lo que puede exponer esto.
- Paths hardcodeados: Tests que referencian
/Users/tunombre/proyecto/van a fallar en un runner Linux. Usa paths relativos o variables de entorno.- Olvidarse del contexto de Docker: Si tu Dockerfile copia archivos con
COPY . ., asegurate de que tu.dockerignoreexcluyanode_modules,.gity otros directorios grandes.- Triggers demasiado amplios: Correr CI en cada push a cada rama desperdicia minutos de runner. Limita los triggers a
mainy pull requests apuntando amain.
Que viene despues
Ahora tenemos un pipeline de CI que hace lint, testea y construye nuestro codigo automaticamente. Pero CI es solo la mitad de la historia. Meter codigo en un contenedor es util, pero ese contenedor necesita ir a algun lado.
En el proximo articulo, vamos a abordar Continuous Deployment (CD): tomar la imagen Docker que acabamos de construir y deployarla a un entorno real. Vamos a cubrir estrategias de deployment, rollbacks y como hacer que los deployments sean aburridos (que es exactamente lo que queres que sean).
Espero que te haya resultado util y que lo hayas disfrutado, hasta la proxima!
Errata
Si encontras algun error o tenes alguna sugerencia, mandame un mensaje para que se corrija.
Tambien podes revisar el codigo fuente y los cambios en los fuentes aca
$ Comentarios
Online: 0Por favor inicie sesión para poder escribir comentarios.