GitHub Actions es CI que vive junto al código. Dejas YAML en .github/workflows/, haces push, y los runners ejecutan jobs en los eventos que elijas: pull request, push a main, un schedule o un clic manual.
Este post es el modelo mental mínimo más las piezas que sí aparecen en pipelines de producción: workflows, jobs, steps, caché, secrets, matrix builds, y los fallos que hacen mentir a los checks verdes o alargan cada run a 12 minutos.
Sin tour del marketplace. Sin lista de "usa todas las features". Un camino de CI que puedas copiar y endurecer.
Qué estás configurando de verdad
Tres capas:
| Capa | Qué es | Archivo / sitio |
|---|---|---|
| Workflow | Pipeline con nombre disparado por eventos | .github/workflows/*.yml |
| Job | Unidad de trabajo en un runner (VM o contenedor) | jobs.<id>: |
| Step | Comando shell o una Action reutilizable | bajo steps: |
Un workflow puede ejecutar varios jobs. Los jobs van en paralelo o esperan con needs. Los steps de un job siempre van en orden en la misma máquina, así que los posteriores ven archivos y env de los anteriores.
Por eso la gente pone "checkout, luego install, luego test" en un solo job y no en tres jobs que arrancan en frío cada uno.
El workflow útil más pequeño
Crea .github/workflows/ci.yml:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- name: Install
run: npm ci
- name: Test
run: npm test
Qué hace:
- Corre en cada PR y en cada push a
main. - Levanta un runner Ubuntu nuevo.
- Hace checkout del repo en el commit bajo prueba.
- Instala Node 20 y restaura caché de npm cuando puede.
- Instala deps con
npm ci(estricto con el lockfile) y corre tests.
Si el step de test sale distinto de cero, el job falla y el check del PR falla. Ese es el contrato completo.
Triggers: on sin ruido
Eventos habituales:
on:
pull_request:
paths:
- "src/**"
- "package-lock.json"
- ".github/workflows/ci.yml"
push:
branches: [main]
workflow_dispatch: # "Run workflow" manual en la UI
schedule:
- cron: "0 6 * * 1" # lunes 06:00 UTC
Notas que evitan re-runs inútiles:
- Los filtros
pathssaltan el workflow cuando el cambio es solo docs (o lo que excluyas). Globs mal puestos son una respuesta clásica a "¿por qué no corrió el CI?" workflow_dispatchsirve para re-ejecutar tras arreglar secrets sin un commit basura.schedulesolo corre en la rama por defecto, y GitHub puede retrasar cron de baja prioridad con carga. No uses solo cron para "tiene que correr antes de cada release."- Los PR de forks tienen acceso limitado a secrets. Es intencional. Trátalo como frontera de seguridad, no como bug.
Jobs, runners y needs
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
needs: lint hace que test espere a que lint tenga éxito. Si lint falla, test y build se saltan (salvo if: que diga lo contrario).
Cada job es una máquina nueva. No hay filesystem compartido entre lint y test salvo que pases artifacts o reinstales. Por eso grafos multi-job ingenuos reinstalan deps tres veces.
Cuándo partir jobs:
- Distinto OS o versiones de lenguaje (matrix).
- Suites lentas e independientes que quieres en paralelo.
- Un job de deploy que no debe correr hasta que pasen tests y necesita secrets extra.
Cuándo dejar un solo job:
- Repos pequeños donde el install domina el wall-clock.
- Steps que comparten caché local en disco (node_modules, target/, .venv).
Steps: run vs uses
run:shell en el runner. En Linux por defecto es bash. Úsalo para scripts del proyecto en los que ya confías.uses:una Action publicada (o local./.github/actions/...). Prefiere pins de versión (@v4o un SHA completo en rutas de alto riesgo).
steps:
- uses: actions/checkout@v4
- name: Run unit tests
run: |
set -euo pipefail
npm ci
npm test -- --coverage
actions/checkout casi siempre es el primer step. Sin él, el runner tiene un workspace vacío.
Para Actions de terceros que tocan secrets o credenciales de deploy, pin al SHA del commit y revisa el código de la Action. Las tags se pueden mover.
Caché de dependencias
La caché evita pagar el install completo en cada run. Prefiere la caché integrada en las setup Actions cuando exista:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm # usa package-lock.json como semilla de la key
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip # requirements.txt / pyproject según config
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
Caché manual cuando haga falta:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
Reglas prácticas:
- La key debe cambiar cuando cambia el lockfile. Keys viejas instalan el árbol incorrecto o se saltan updates necesarios.
- La caché es best-effort. Un miss es normal tras 7 días de inactividad o con key nueva. El job debe funcionar en frío.
- No caches secrets ni outputs de build que no pondrías en un artifact de PR. Caché es para deps y toolchains, no para "saltar tests si cacheamos el binario de anoche."
- Restore-keys son prefijos: un hit parcial restaura una caché más vieja y encima se apila. Acelera installs, pero puede tapar errores de lockfile si el install no es estricto.
Los artifacts son otra cosa: actions/upload-artifact / download-artifact pasan outputs entre jobs del mismo run del workflow. Caché cruza runs; artifacts viven dentro de un run (con la retención que configures).
Secrets y variables de entorno
Los secrets del repo u org viven en la configuración de GitHub. Los workflows los leen así:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # opcional: reglas de protección, secrets por env
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
APP_ENV: production
run: ./scripts/deploy.sh
Reglas duras:
- Nunca hagas echo de secrets en los logs. GitHub enmascara valores de secret conocidos, no cada string derivado. Imprimir
API_TOKENen base64 o un substring puede filtrar. - No subas secrets "solo para CI" al repo ni al YAML del workflow.
pull_requestdesde forks no obtiene write secrets igual quepull_request_target. Prefierepull_requestpara código no confiable. Solo usapull_request_targetsi entiendes el modelo de checkout y privilegios; el mal uso es un footgun clásico de supply chain.- OIDC + roles cloud (AWS, GCP, Azure) ganan a las keys de larga vida cuando puedes. Tokens de corta vida con
permissions+ federación dejan menos residuo permanente. - Acota
permissions:arriba del workflow o por job. Los derechos por defecto del token se han endurecido; aun así, declara lo que necesitas:
permissions:
contents: read
pull-requests: write # solo si un bot debe comentar
Matrix builds
Matrix convierte una definición de job en muchos runs:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: ["18", "20", "22"]
exclude:
- os: windows-latest
node: "18"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
Qué vigilar:
- Coste y cola crecen con el producto de dimensiones. Tres OS por cuatro versiones son doce jobs.
fail-fast: true(por defecto) cancela hermanos cuando falla una celda. Ponfalsecuando quieras el mapa completo de fallos en un PR.include/excludemantienen la rejilla honesta en lugar de listar cada combinación a mano.- Diferencias de path y shell por OS aparecen aquí primero (
\vs/, PowerShell vs bash). Prefiere scripts del proyecto que lo abstracten.
Un ejemplo multi-lenguaje más cerrado
Servicio con front Node y tests Python:
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: api/requirements.txt
- run: pip install -r requirements.txt
- run: pytest -q
docker:
runs-on: ubuntu-latest
needs: [frontend, backend]
steps:
- uses: actions/checkout@v4
- run: docker build -t app:ci .
concurrency cancela un run anterior en progreso de la misma rama cuando vuelves a pushear. Controla minutos de Actions y la confusión de "¿cuál es el run más reciente?"
Errores comunes (los caros)
1. Probar un commit distinto del que crees
Siempre haz checkout del head del PR para CI de PR no confiable. Un ref mal puesto en checkout (o pull_request_target con el merge ref a la ligera) puede ejecutar workflow de confianza contra código controlado por un atacante. actions/checkout@v4 por defecto en pull_request es la base segura para la mayoría de equipos.
2. npm install en lugar de npm ci en CI
npm install puede reescribir el comportamiento del lockfile y desviarse de lo que instalaron los devs. CI debe usar la ruta estricta del lockfile (npm ci, pnpm install --frozen-lockfile, yarn install --immutable, etc.).
3. Cachear node_modules a pelo
Funciona hasta que deja de funcionar (optional deps, addons nativos, sesgo de OS). Prefiere cachear el store del package manager e instalar limpio. El cache: npm de setup-node es el default aburrido y correcto para npm.
4. Secrets en steps run: echo de "debug"
Alguien deja echo "token=$API_TOKEN" "cinco minutos." Los logs viven más que el PR. Depura en local o asume que los logs son legibles para quien tenga el acceso de lectura del run.
5. Sin concurrency, deploys solapados sin fin
Dos pushes a main pueden desplegar fuera de orden. Usa grupos de concurrency en workflows de deploy, o un environment con reviewers / timers.
6. Explosión de matrix sin fail-fast: false al depurar
Ves una X roja, cancelas hermanos y te pierdes que Windows+Node 22 también estaba roto. Cambia fail-fast mientras estabilizas; vuelve a true cuando la suite sea confiable.
7. Confiar en tags latest para Actions críticas
uses: some-org/some-action@main puede cambiar un martes sin avisar. Pin al menos majors (@v4). Pin SHAs en deploy y release.
8. Olvidar required checks en ramas protegidas
El workflow existe, el PR se mergea con una X roja ignorada. Branch protection (o rulesets) debe exigir los nombres de job que te importan. Renombras un job sin actualizar el check requerido y los merges se liberan.
9. Sorpresa de permisos de GITHUB_TOKEN
Un step que abre un issue o pushea un tag falla con 403 tras un cambio de defaults. Pon permissions explícitas y un PAT o GitHub App solo cuando el token por defecto no baste.
10. Tests flaky culpando a Actions
Reintentar el workflow entero esconde bugs de producto. Arregla flakes o cuarentenalos con una lista explícita. "Re-run jobs" infinito no es estrategia de estabilidad.
Expresiones, contexts e if
- name: Publish
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./scripts/publish.sh
Contexts útiles: github, env, secrets, matrix, needs, runner, steps.
- name: Upload coverage
if: always() && steps.test.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
if: always() corre aunque un step anterior fallara (sigue sujeto a cancelación del job). Úsalo con cuidado para cleanup o uploads que de verdad quieras en fallo.
Iterar en local sin quemar minutos
- Corre los mismos scripts que Actions:
npm ci && npm test. Si falla en local, fallará en CI. - Act aproxima workflows con Docker. No es idéntico a runners hosted de GitHub (imágenes, software disponible, red).
- Usa
workflow_dispatchcon inputs para probar deploys a staging sin mergear.
on:
workflow_dispatch:
inputs:
target:
description: "deploy target"
required: true
default: staging
Checklist de un default sensato
Antes de dar CI por "listo":
- Workflow en
pull_requestypusha la rama por defecto. - Installs estrictos con lockfile.
- Setup Action del lenguaje con caché integrada.
permissions: contents: readexplícito (añade más solo si hace falta).concurrencyen ramas de PR.- Required status checks en ramas protegidas con los nombres exactos de job.
- Secrets solo vía
${{ secrets.* }}/ OIDC, nunca en git. - Majors pinneados en Actions de first-party; SHA en deploy.
- Un camino documentado de "re-run / manual dispatch" para releases.
- Presupuesto de tiempo: si install+test pasa de ~10 minutos, perfila antes de añadir celdas de matrix.
Cómo se ve el éxito
Abre un PR, los checks arrancan en menos de un minuto, el install es casi todo cache hit, los tests coinciden con lo que corren los devs, y una X roja significa "este commit está mal," no "el runner tuvo un mal día" ni "probamos el ref equivocado."
Empieza por el workflow de un solo job Node (o Python) de arriba. Añade matrix cuando soportes varios runtimes. Parte jobs cuando el wall-clock en paralelo o el aislamiento de deploy valgan el coste de reinstalar. Cachea deps, no excusas.
Cuando algo está verde y aún no te fías, el bug casi siempre está en qué corres o en qué commit lo corres, no en la indentación del YAML.
