GitHub Actions: CI/CD sin el drama
TL;DR
- Workflows se componen de jobs, que se componen de steps — entender esa jerarquía es el 80% del modelo mental
needsdefine dependencias entre jobs — sin él, todos los jobs corren en paralelo- Matrix builds para testear en múltiples versiones sin duplicar código
- Caching de dependencias correctamente puede reducir el tiempo de CI de 5 minutos a 1
- Para debuggear:
actpara correr localmente,tmatepara SSH al runner, yechoestratégico
Si alguna vez hiciste 15 commits seguidos que decían “fix ci”, “fix ci again”, “please work”, “ok seriously fix ci” — este post es para ti. El problema casi siempre es el mismo: no entender la diferencia entre workflows, jobs, y steps, y no saber que los jobs corren en paralelo por default.
El modelo mental: workflows → jobs → steps
# .github/workflows/ci.yml
name: CI
on: [push, pull_request] # cuándo corre el workflow
jobs: # colección de jobs (corren en paralelo por defecto)
test: # nombre del job
runs-on: ubuntu-latest # qué máquina usar
steps: # lista de acciones secuenciales dentro del job
- uses: actions/checkout@v4 # action predefinida
- name: Run tests # step con nombre descriptivo
run: bundle exec rspec # comando shell
Workflow = el archivo YAML completo. Se dispara por eventos (push, PR, schedule).
Job = una “máquina virtual” que se levanta, corre sus steps, y se destruye. Cada job es independiente — no comparten filesystem ni estado.
Step = un comando o una action dentro de un job. Los steps en un job corren secuencialmente; si uno falla, el job falla.
El error más común al empezar: asumir que los jobs comparten estado. No lo hacen. Si haces git clone en el job A, el job B no tiene ese código — cada job empieza de cero.
needs: ordenando la ejecución
Por defecto, todos los jobs en un workflow corren en paralelo. Para dependencias, usa needs:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bundle exec rspec
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bundle exec rubocop
build:
runs-on: ubuntu-latest
needs: [test, lint] # espera que test Y lint pasen
steps:
- uses: actions/checkout@v4
- run: docker build .
deploy:
runs-on: ubuntu-latest
needs: build # solo corre si build pasó
if: github.ref == 'refs/heads/master' # solo en master
steps:
- run: ./deploy.sh
test ──┐
├── build ── deploy
lint ──┘
Sin needs: test, lint, y build corren en paralelo desde el inicio — el deploy podría intentar deployar código que no pasó los tests.
Matrix builds
Para testear en múltiples versiones sin copiar el job:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
ruby: ['3.2', '3.3', '3.4']
# También puedes combinar dimensiones:
# os: [ubuntu-latest, macos-latest]
# ruby: ['3.2', '3.3']
# → genera 6 combinaciones
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rspec
Esto crea tres jobs independientes (uno por versión de Ruby) que corren en paralelo. Si Ruby 3.2 falla pero 3.3 y 3.4 pasan, ves exactamente cuál versión es el problema.
Para excluir combinaciones problemáticas:
strategy:
matrix:
ruby: ['3.2', '3.3', '3.4']
os: [ubuntu-latest, macos-latest]
exclude:
- ruby: '3.2'
os: macos-latest # esta combinación específica no nos importa
Caching bien hecho (de 5 minutos a 1)
Sin cache, cada job descarga e instala todas las dependencias desde cero. Con cache:
# Para Ruby/Bundler
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true # esto maneja el cache automáticamente
# La action cachea según Gemfile.lock
# Si Gemfile.lock no cambió, usa el cache → ~30s en vez de ~3min
# Para Node.js
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # cachea node_modules según package-lock.json
# Cache manual (para casos más específicos)
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
El key es la clave del cache — si cambia, se invalida. hashFiles('Gemfile.lock') genera un hash del archivo: si Gemfile.lock cambia, el hash cambia, el cache se invalida, se reinstala todo.
Secrets vs env vars vs outputs
# Secrets — valores sensibles, solo disponibles como variables de entorno
# Se configuran en Settings → Secrets and variables → Actions
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # valor secreto
API_TOKEN: ${{ secrets.PRODUCTION_API_TOKEN }}
# Variables de entorno normales — visibles en los logs
env:
RAILS_ENV: test
NODE_ENV: test
# Outputs — pasar valores de un step a otro dentro del mismo job
- name: Get version
id: version
run: echo "tag=$(git describe --tags)" >> $GITHUB_OUTPUT
- name: Use version
run: echo "Deploying version ${{ steps.version.outputs.tag }}"
# Para pasar entre jobs (requiere outputs declarados en el job)
jobs:
build:
outputs:
image_tag: ${{ steps.build.outputs.tag }}
steps:
- id: build
run: |
TAG=$(git rev-parse --short HEAD)
echo "tag=$TAG" >> $GITHUB_OUTPUT
deploy:
needs: build
steps:
- run: docker pull myapp:${{ needs.build.outputs.image_tag }}
Un workflow completo para Rails
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
RAILS_ENV: test
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_test
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Setup database
run: |
bundle exec rails db:create
bundle exec rails db:schema:load
- name: Run tests
run: bundle exec rspec
- name: Upload coverage
uses: codecov/codecov-action@v4
if: always() # sube aunque fallen los tests
with:
token: ${{ secrets.CODECOV_TOKEN }}
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- run: bundle exec rubocop --parallel
Debuggear sin hacer 40 commits
El loop “push → esperar → ver error → corregir → push” es agónico. Alternativas:
act para correr workflows localmente:
# Instala act (macOS)
brew install act
# Corre el workflow de CI localmente
act push
# Corre un job específico
act push --job test
act usa Docker para simular el entorno de GitHub Actions. No es 100% idéntico, pero atrapa el 80% de los errores sin hacer push.
tmate para SSH al runner en tiempo real:
- name: Debug with tmate
uses: mxschmitt/action-tmate@v3
if: failure() # solo si algo falló
# Esto pausa el workflow y te da una URL SSH para conectarte
echo estratégico:
- name: Debug environment
run: |
echo "Ruby version: $(ruby --version)"
echo "Working directory: $(pwd)"
ls -la
env | grep -i rails | sort
GitHub Actions tiene una curva de aprendizaje rara — el YAML es simple pero los conceptos de jobs vs steps, caching, y secrets toman algo de tiempo. Una vez que tienes el modelo mental claro, el resto es documentación.
Instala act hoy. En serio — debuggear localmente te ahorra literalmente una hora de push-esperar-ver-error-corregir-push por cada workflow que configures. Que no sean tus commits de CI los que definen la historia de tu repo.