testsruns-on: ubuntu-24.04
- checkout
- setup-python 3.13 con caché de pip
- pip install -r requirements-dev.txt
- pytest --junit-xml
- si falla: subir el reporte
Ronda de Diseño ML · CI/CD · ejemplos de C01
API, Contenedor y OrquestaciónPara trabajar en el repo del equipo sin miedo, escribir un workflow con la documentación abierta y diagnosticar un CI en rojo rápido. Git guarda tu trabajo en commits y ramas; GitHub lo revisa en pull requests; GitHub Actions lo prueba, construye la imagen y la despliega. Cada sección trae la sintaxis como plantilla con variables, las piezas, un ejemplo real del curso, una figura, un truco para recordarla, la trampa típica y un laboratorio opcional. En la versión web, al final hay un diagnosticador de síntomas, un revisor de workflows y un quiz de errores.
Cómo recordarlaTrampa típicavariablela cambias túEjemplos en verde
En las figuras:tú y tu máquinaGitHub y el remotoautomático (Actions)nuevo, avanza o seguroespera o cambiase pierde, reescribe o fallasolo en el reflog
¿Qué estados, nombres y números de git y de GitHub Actions conviene saberse de memoria para leer un repo o una ejecución de CI (las pruebas automáticas de cada cambio) en segundos?
Sintaxis con variables
git status -sbLa foto rápida del repo. Cada archivo lleva dos letras: la izquierda es el staging (lo que entra en el próximo commit, la próxima foto del proyecto que guardes) y la derecha, el working tree (tus archivos en disco). La primera línea dice tu rama (la etiqueta que avanza con tus commits), la rama del remoto a la que está conectada (su copia en GitHub) y cuántos commits tienes de más o de menos que ella: [ahead 1, behind 1].
git log --oneline --graph --all -n n
La historia, un commit por línea: el SHA corto (el nombre del commit), entre paréntesis las etiquetas que apuntan ahí y el mensaje. HEAD -> main es dónde estás; origin/main es lo último que viste del remoto (la copia del repo en GitHub).
git rev-parse --short refTraduce un nombre (HEAD, main, origin/main, HEAD~1) al SHA de su commit; sin --short, da los 40 caracteres. Con --short acepta un solo nombre por vez y da 7 caracteres en un repo chico, más cuando el repo crece. En el C01, el tag (el nombre de versión) de la imagen Docker del gateway son los primeros 7: --short=7 los da aunque el repo crezca, como ${GITHUB_SHA::7} en CI.
git reflog -n nEl diario de HEAD en tu máquina: cada vez que HEAD se mueve (un commit, un cambio de rama, un reset que mueve la rama a otro commit, un merge o un rebase que juntan ramas) queda una línea HEAD@{n}. No viaja con push (subir al remoto) ni con clone (copiar el repo). Si un commit "desapareció", búscalo aquí.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| n | cuántas líneas mostrar | 20 |
| ref | cualquier nombre de un commit: rama, HEAD, HEAD~1, origin/main o un SHA | HEAD |
| bytes del contenido del commit; git lo escribe en un encabezado antes de calcular el SHA | 244: git cat-file -s HEAD | |
| la foto de los archivos (tree): nombre, permisos y SHA del contenido de cada uno | tree d6cd4c0… | |
| SHA del commit anterior: ninguno en el primero, uno casi siempre, dos en un merge (el commit que junta dos ramas) | parent 9798182… | |
| ref~n | n pasos hacia atrás, siguiendo siempre el primer padre | HEAD~1 = el commit anterior |
| ref^2 | el segundo padre de un merge: la punta de la rama que entró | HEAD^2 en el merge de un PR (pull request) = el último commit de la rama |
| cuánto vive una línea del reflog (gc.reflogExpire) | 90 días | |
| cuánto vive si su commit ya no está en ninguna rama, como tras un reset --hard (gc.reflogExpireUnreachable); después, git gc (la limpieza que git corre sola de vez en cuando) puede borrar el commit | 30 días |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| ?? | Untracked: un archivo nuevo que git todavía no sigue. No entra en ningún commit hasta que hagas git add. | ?? .env (va al .gitignore, no a un commit) |
| M a la derecha | Cambió en disco, pero está fuera del staging: el próximo commit no lo lleva. | M gateway/app/preprocess.py |
| M a la izquierda | Cambió y está en staging: el próximo commit lo lleva. | M gateway/app/main.py |
| MM | Está en staging y lo volviste a cambiar después: el commit lleva la versión de tu git add, no la última. | MM gateway/app/main.py |
| A · D · R | Nuevo, borrado o renombrado, ya en staging. | R gateway/app/preprocess.py -> gateway/app/preproceso.py |
| UU | Conflicto sin resolver: las dos ramas cambiaron las mismas líneas. Se resuelve editando el archivo y con git add. | UU gateway/app/main.py |
| HEAD | El "estás aquí": casi siempre apunta a una rama, y la rama a un commit. | cat .git/HEAD → ref: refs/heads/main |
| HEAD (no branch) | Detached HEAD: HEAD apunta directo a un commit, sin rama. Lo que commitees ahí no lo guarda ninguna etiqueta. | ## HEAD (no branch) |
| main = refs/heads/main | Una rama: un archivo con el SHA de su último commit. Avanza sola con cada commit que haces estando en ella. | .git/refs/heads/main (41 bytes) |
| origin/main | Rama de seguimiento (remote-tracking): tu última foto del main del remoto. Solo se mueve con fetch, pull o push. | [behind 1] = el remoto tiene 1 commit que tú no |
| upstream | La rama remota a la que está conectada la tuya: de ahí trae pull, ahí sube push y contra ella te compara status. En la sintaxis de git se escribe @{u}. | git log --oneline @{u}.. (tus commits sin subir) |
| SHA | El nombre de un commit: 40 caracteres hexadecimales. Los primeros 7 bastan en un repo chico; git usa más si hace falta. | 3f9c2ab = classifier-gateway:3f9c2ab |
| HEAD~1 · HEAD^2 · HEAD@{2} | El commit anterior; el segundo padre de un merge; dónde estaba HEAD hace dos movimientos (reflog). | git show HEAD~1 |
| ORIG_HEAD · FETCH_HEAD | Notas que deja git: dónde estaba HEAD antes de un reset, merge o rebase; y lo último que trajo fetch. | git reset --hard ORIG_HEAD deshace un merge recién hecho |
| Qué | Default o límite | Qué hacer |
|---|---|---|
| Rama inicial de git init | master (Git 3.0, todavía sin fecha, la cambiará a main). GitHub crea main, y el git de Apple (Command Line Tools de Xcode) ya trae main en su configuración | git init -b main: todo el equipo habla de la misma rama |
| SHA de un commit | 40 caracteres hex (SHA-1); git muestra 7 en un repo chico, más si el repo crece | El tag de la imagen son los primeros 7: git rev-parse --short=7 HEAD (en CI, ${GITHUB_SHA::7}) |
| Reflog | 90 días; 30 si el commit ya no está en ninguna rama | Tu red tras un reset o un rebase. Es local: no viaja con push ni con clone |
| git pull con ramas divergentes (cada una tiene commits que la otra no) | Sin pull.rebase ni pull.ff configurados, falla (desde Git 2.33.1): Need to specify how to reconcile divergent branches. | Decide una vez: git config --global pull.ff only (solo avanza; si divergieron, eliges tú) o pull.rebase true (tus commits van encima) |
| Archivos grandes en GitHub | Aviso arriba de 50 MiB; bloqueo arriba de 100 MiB | Pesos (.pt, .onnx, .plan) fuera de git: registry de modelos (donde se guardan sus versiones), S3 o Git LFS (guarda el archivo aparte y deja en git solo un puntero) |
| Contraseña de GitHub en git | No se acepta desde el 13 de agosto de 2021 | Una llave SSH, un token (una clave que creas en GitHub) o gh auth login (sección GitHub) |
| timeout-minutes de un job (un grupo de pasos que corre en una máquina) | 360 min. En los runners de GitHub (las máquinas que presta), un job muere a las 6 h; en uno self-hosted (tuyo), a los 5 días | Pon el tuyo: un job colgado quema minutos hasta el tope |
| Retención en Actions | 90 días para artifacts (archivos que guarda un job) y logs; desde el 1 de octubre de 2026, también para checks, runs y statuses (cada ejecución y sus resultados). Se ajusta: repos públicos, 1–90 días; privados, 1–400 | Lo que deba durar más (reportes, evidencia), guárdalo fuera de Actions |
| Caché de dependencias | 10 GB por repo (más, solo con un medio de pago); se borra lo que nadie usa en 7 días | Si la caché nunca se reusa, casi siempre su key (el nombre con que se guarda y se busca) está mal armada |
| GITHUB_TOKEN (la credencial que GitHub le da a cada job) | En un repo personal o una organización nuevos: solo lectura de contents y packages, y sin permiso para crear o aprobar PRs (pull requests). Vence al terminar el job: 6 h como máximo en runners de GitHub; en self-hosted se renueva hasta 24 h | Pide en permissions: solo lo que el job necesita |
| Eventos hechos con GITHUB_TOKEN | No disparan workflows (los procesos automáticos de Actions), salvo workflow_dispatch y repository_dispatch (los disparos a mano o por API). Si abre o actualiza un PR, los runs de ese PR esperan aprobación | Si un workflow abre el PR del manifest (el YAML de Kubernetes con el tag de la imagen) con GITHUB_TOKEN, su CI no arranca solo: alguien con permiso de escritura aprueba los runs (o el PR se abre con el token de una GitHub App) |
| Secrets (claves guardadas en GitHub) en PRs desde forks (copias del repo en otra cuenta) | No llegan al runner (salvo GITHUB_TOKEN) | Un fork no puede usar tus llaves: no pongas deploys en esos PRs |
Verbos de bolsillo, en orden: status (qué hay) → diff (qué cambió) → log (de dónde viene) → reflog (dónde estuve). Y git help {comando} abre el manual de tu versión.
$ git status -sb
## feat/gateway-timeout...origin/feat/gateway-timeout [ahead 1]
M gateway/app/main.py
M gateway/app/preprocess.py
?? .env
$ git log --oneline --graph --all
* 4f4fd71 (HEAD -> feat/gateway-timeout) gateway: deadline total de 250 ms
* 3fb9f47 (origin/feat/gateway-timeout) gateway: bajar INFER_TIMEOUT_S a 120 ms
* a469e77 (origin/main, main) gateway: primera versión[ahead 1]: tienes 1 commit (4f4fd71) que el remoto no tiene; git push lo sube. [behind 1] sería lo contrario: te falta 1, y git pull lo trae.git add.?? .env: un archivo que git no sigue. Es un secreto local: va al .gitignore (la lista de lo que git ignora), nunca a un commit (sección Día a día).HEAD -> feat/gateway-timeout es dónde estás y origin/… es tu última foto del remoto. main y origin/main están en el mismo commit: al día, según tu último fetch.Cómo recordarla
Lee git status -sb en dos columnas: izquierda = staging, derecha = disco. HEAD es "estás aquí", una rama es una etiqueta con un SHA y origin/main es tu última foto del remoto. Números: SHA de 40 hex, reflog 90 / 30 días, job de Actions 360 min.
Trampa
Leer origin/main como el remoto en vivo: es tu foto del último fetch y puede tener días. git status no le pregunta nada al remoto, así que "up to date" puede ser falso: corre git fetch antes de mirar. Y creer que push sube el reflog o el stash (los cambios que guardaste aparte con git stash): los dos viven solo en tu máquina.
En el curso: M10 · Reproducibilidad: los cinco ingredientes M5 · Registries y tags inmutables M10 · CI/CD para modelos: la pirámide de tests Herramientas · Infraestructura como código, CI y seguridad
Qué vas a provocar: Provocar los estados de git status, ver qué entra en un commit cuando el archivo cambió después del add, y traducir nombres a SHAs.
Necesitas: git 2.28 o más nuevo (git --version) y tu nombre y email configurados (git config --global user.name y user.email). Todo es local: no necesitas cuenta de GitHub.
mkdir ~/lab-git && cd ~/lab-git && git init -b main
mkdir -p gateway/app
printf 'INFER_TIMEOUT_S = 0.15\n' > gateway/app/main.py
printf 'RESIZE_SHORT = 256\n' > gateway/app/preprocess.py
git add . && git commit -m "gateway: primera versión"Qué deberías ver: Initialized empty Git repository in /Users/<tú>/lab-git/.git/ y después [main (root-commit) <sha>] gateway: primera versión, con 2 files changed, 2 insertions(+). El <sha> cambia en cada máquina.
printf 'INFER_TIMEOUT_S = 0.12\n' > gateway/app/main.py
git add gateway/app/main.py
printf 'RESIZE_SHORT = 224\n' > gateway/app/preprocess.py
printf 'TOKEN=no-lo-subas\n' > .env
git status -sQué deberías ver: Tres líneas: M gateway/app/main.py (la M a la izquierda: está en staging), luego gateway/app/preprocess.py con la M a la derecha (cambió solo en disco) y ?? .env (git no lo sigue).
printf '# probar 100 ms\n' >> gateway/app/main.py
git status -sQué deberías ver: MM gateway/app/main.py: una M por columna. El staging tiene el 0.12 de tu add; el disco tiene además la línea nueva.
git diff --stagedQué deberías ver: Lo que entraría en el commit: -INFER_TIMEOUT_S = 0.15 y +INFER_TIMEOUT_S = 0.12. Nada de la línea # probar 100 ms.
git diffQué deberías ver: Lo que está en disco y no en staging: +# probar 100 ms en main.py, y -RESIZE_SHORT = 256 / +RESIZE_SHORT = 224 en preprocess.py.
git commit -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"
git status -sQué deberías ver: [main <sha>] gateway: bajar INFER_TIMEOUT_S a 120 ms con 1 file changed, 1 insertion(+), 1 deletion(-). Después siguen tres líneas, ahora con la M a la derecha en main.py: la línea # probar 100 ms no entró al commit.
git log --oneline
git rev-parse --short HEAD
git rev-parse --short HEAD~1
git rev-parse HEAD
cat .git/HEADQué deberías ver: Dos commits en el log, dos SHAs cortos de 7 caracteres (HEAD y HEAD~1), el SHA completo de 40 y ref: refs/heads/main: HEAD apunta a la rama, no al commit. Si le pasas dos nombres a rev-parse --short, responde fatal: Needed a single revision.
git reflogQué deberías ver: Dos líneas, la más nueva arriba: HEAD@{0}: commit: gateway: bajar INFER_TIMEOUT_S a 120 ms y HEAD@{1}: commit (initial): gateway: primera versión. En la terminal, la primera lleva además (HEAD -> main).
cd ~ && rm -rf ~/lab-gitQué deberías ver: No imprime nada. Va con -f porque git guarda sus objetos como solo lectura y rm -r te preguntaría por cada uno (override r--r--r--…?).
Cómo arreglarlo y confirmarlo
Si un archivo entró (o no entró) al commit por sorpresa, lee git status -s antes de commitear: la columna izquierda es lo que entra. Para sacar algo del staging: git restore --staged {ruta}.
Probado de verdad en una carpeta de prueba (git 2.50.1). Los SHAs y las rutas cambian en cada máquina.
¿Qué pasa, en orden, desde que editas un archivo hasta que esa versión recibe tráfico en producción, y quién hace cada paso?
Sintaxis con variables
git switch main && git pull
git switch -c ramaCada cambio va en su propia rama (una etiqueta que avanza con cada commit, cada foto del proyecto que guardas), creada desde la versión más nueva de main, la rama principal. Los nombres dicen qué es: feat/… para algo nuevo, fix/… para un arreglo.
git add ruta
git commit -m "mensaje"Eliges qué entra (add) y guardas la foto con un mensaje (commit). El commit vive solo en tu máquina hasta el push.
git push -u origin ramaSube la rama a GitHub (origin es el nombre corto del repo del equipo). -u la conecta con origin/rama, su copia en GitHub, la primera vez; después basta git push. GitHub responde con líneas remote: que traen el link para abrir el pull request (PR: la propuesta de juntar tu rama con main).
git rev-parse --short=7 HEADDespués del merge (cuando el PR entra a main), parado en main: los primeros 7 caracteres del SHA (el nombre único) del commit que construye CD (la entrega continua). Son el tag de la imagen, classifier-gateway:sha, igual que ${GITHUB_SHA::7} en CI (integración continua): de un pod (una copia del servicio corriendo en Kubernetes) llegas al código exacto. Sin =7, git da más caracteres cuando el repo crece.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| rama | nombre de la rama: tipo/qué-cambia | feat/gateway-timeout, fix/preprocess-resize |
| ruta | archivo que cambiaste | gateway/app/main.py |
| mensaje | qué cambió y dónde, en una línea | gateway: bajar INFER_TIMEOUT_S a 120 ms |
| sha | los primeros 7 caracteres del SHA del commit de main | 3f9c2ab, el tag del gateway en k8s/gateway.yaml del C01 |
| desde el git push hasta el primer request atendido por la versión nueva | la suma de abajo; con GitOps (el cluster copia lo que dicen los manifests, los YAML de Kubernetes guardados en git), más lo que tarde en sincronizar | |
| CI (integración continua): instalar dependencias y correr los tests en el runner, la máquina limpia donde corre CI | los 24 tests del gateway, sin GPU | |
| lo que tarda una persona en leer y aprobar | depende del equipo, no de la máquina | |
| construir la imagen (el paquete con el código y sus dependencias) para linux/amd64, los nodos x86 del cluster, y subirla a ECR, el almacén de imágenes (registry) de AWS | con la caché de capas solo se rehace la del código (cheat sheet de Docker) | |
| Kubernetes cambia los pods (las copias del servicio que corren) de a poco hasta que todos pasan la readiness (el chequeo de "listo para recibir tráfico") | el cheat sheet de Kubernetes le da 5m a rollout status del gateway |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| working tree | Tus archivos en disco: donde editas. | gateway/app/main.py abierto en tu editor |
| staging (index) | La lista de lo que entra en el próximo commit. | git add gateway/app/main.py |
| commit | Una foto de todo el proyecto, con autor, fecha, mensaje y el commit anterior (su padre). | 3387071 gateway: bajar INFER_TIMEOUT_S a 120 ms |
| rama de feature | Etiqueta propia para un cambio: sale de main y vuelve por un PR. | feat/gateway-timeout |
| git push | Sube tus commits al remoto (origin, el repo del equipo en GitHub). | git push -u origin feat/gateway-timeout |
| pull request (PR) | Propuesta de juntar tu rama con main: el diff (las líneas que cambian), la conversación, los checks y la review en un solo lugar. | PR #42: gateway: bajar INFER_TIMEOUT_S a 120 ms |
| CI (integración continua) | Un workflow de GitHub Actions (un archivo YAML que dice qué correr y cuándo) corre los tests en cada push y en cada PR, en una máquina limpia. | pytest con los 24 tests del gateway |
| check | El resultado de cada job de CI (un grupo de pasos del workflow), pegado al PR: verde (pasó), rojo (falló) o pendiente. | el job de tests del gateway en verde |
| review | Otra persona lee el diff y aprueba (Approve) o pide cambios (Request changes). | un comentario sobre la línea de INFER_TIMEOUT_S |
| protección de rama / ruleset | Reglas sobre main: nada de push directo; el merge espera checks verdes y aprobación. | Require status checks to pass before merging |
| merge | Junta la rama con main. GitHub ofrece tres formas: merge commit (un commit que junta las dos), squash (todo en un solo commit nuevo) o rebase (tus commits copiados encima); más en la sección Ramas. | Merge pull request #42 from equipo/feat/gateway-timeout |
| CD (entrega continua) | Otro workflow, en el push a main: construye la imagen, la sube y actualiza lo que corre en producción (el deploy). | buildx para linux/amd64 → ECR |
| tag = SHA | La imagen se nombra con los primeros 7 caracteres del SHA del commit de main. El repo de ECR del C01 tiene tags inmutables: nadie puede reusar ese nombre para otra imagen. | classifier-gateway:3f9c2ab |
| rollout · revert | Kubernetes reemplaza los pods de a poco por los de la imagen nueva. Si sale mal, un revert (commit que deshace otro) vuelve atrás dejando rastro. | kubectl rollout status deployment/gateway |
Tú, en tu máquina
Editar
git switch -c feat/gateway-timeoutRama nueva desde el main al día. Editas gateway/app/main.py: el cambio vive en tu working tree (tus archivos en disco).
Staging
git add gateway/app/main.pyEliges qué entra en la foto. git diff --staged muestra exactamente eso.
Commit
git commit -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"La foto queda en tu repo local, con un SHA nuevo. Nadie más la ve todavía.
Push
git push -u origin feat/gateway-timeoutLa rama sube a GitHub y queda conectada con origin/feat/gateway-timeout.
En GitHub, entre personas
Pull request
gh pr create --fillCon gh, la CLI de GitHub (su programa de terminal), o con el botón Compare & pull request en la web. El PR muestra el diff contra el punto donde nació la rama.
Automático: CI con GitHub Actions
CI
cd gateway && pip install -r requirements-dev.txt && pytestUn workflow de GitHub Actions corre los 24 tests del gateway, sin GPU, sobre un merge de prueba de tu rama con main. El resultado es un check del PR.
En GitHub, entre personas
Review
git diff main...feat/gateway-timeoutOtra persona lee el mismo diff (con tres puntos, contra el punto donde nació la rama) y aprueba o pide cambios. Con protección de rama, el merge espera checks verdes y la aprobación.
Merge
El botón del PR: merge commit (como git merge --no-ff), squash o rebase. main recibe commits nuevos (uno solo con merge commit o squash; con rebase, uno por cada commit de la rama), y el SHA de su punta es el que viaja a producción.
Automático: CD con GitHub Actions
CD: imagen
docker buildx build --platform linux/amd64 -t $REG/classifier-gateway:$SHA --push gateway/Otro workflow, en el push a main: entra a AWS por OIDC (GitHub le da al job un token de corta vida y AWS lo cambia por credenciales temporales: sin llaves guardadas), construye para los nodos amd64 y sube a ECR. El tag son los primeros 7 caracteres del commit de main y, con los tags inmutables del C01, no se puede pisar. $REG es el registry de ECR del C01 (123456789012.dkr.ecr.us-east-1.amazonaws.com). $SHA: en CI, ${GITHUB_SHA::7}; el release.sh del curso usa git rev-parse --short=7 HEAD, que da esos mismos 7 (o más, si otro objeto empieza igual).
Manifest
kubectl -n classifier set image deployment/gateway gateway=$REG/classifier-gateway:$SHAEl Deployment (el objeto de Kubernetes que mantiene corriendo el gateway) pasa al tag nuevo. Con GitOps, en vez de este comando, un PR cambia la línea image: de k8s/gateway.yaml y Argo CD (el programa que aplica al cluster lo que dice git) la aplica.
Kubernetes: hacia el tráfico
Rollout
kubectl -n classifier rollout status deployment/gateway --timeout=5mKubernetes cambia los pods de a poco; los nuevos reciben tráfico cuando pasan la readiness. Si algo sale mal: revert (sección Deshacer) o volver al tag anterior.
túpersonas en GitHubautomático (Actions)Kubernetes
$ git switch main && git pull
Already on 'main'
Your branch is up to date with 'origin/main'.
Already up to date.
$ git switch -c feat/gateway-timeout
Switched to a new branch 'feat/gateway-timeout'
# … editas gateway/app/main.py …
$ git diff
…
-INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.15")) # 150 ms para Triton dentro del p99 ≤ 200 ms
+INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.12")) # 120 ms para Triton dentro del p99 ≤ 200 ms
…
$ git add gateway/app/main.py
$ git commit -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"
[feat/gateway-timeout 3387071] gateway: bajar INFER_TIMEOUT_S a 120 ms
1 file changed, 1 insertion(+), 1 deletion(-)
$ git push -u origin feat/gateway-timeout
…
* [new branch] feat/gateway-timeout -> feat/gateway-timeout
branch 'feat/gateway-timeout' set up to track 'origin/feat/gateway-timeout'.
# En GitHub: abres el PR, CI corre los tests, alguien aprueba y se hace el merge
$ git switch main && git pull
…
Updating 9798182..27bd258
Fast-forward
gateway/app/main.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
$ git rev-parse --short=7 HEAD
27bd258git switch main && git pull antes de empezar: tu rama nace del main más nuevo. Ojo con Your branch is up to date: es según tu último fetch (la última vez que bajaste novedades del remoto); el Already up to date. de pull sí le preguntó al remoto.gateway/app/main.py: - lo que se va y + lo que entra. Es lo mismo que verá quien revise el PR.-u conecta tu rama con origin/feat/gateway-timeout: desde ahí, git push y git pull saben a dónde ir. Contra GitHub, el push imprime además líneas remote: con el link del PR.refs/pull/N/merge), no tu rama sola. Y si el PR tiene conflictos (cambios que chocan con los de main), los workflows de pull_request no corren.27bd258 es el commit de main que construye CD: la imagen queda como classifier-gateway:27bd258. En los archivos del C01, ese lugar lo ocupa 3f9c2ab.Cómo recordarla
Git guarda, GitHub revisa, Actions prueba y construye, Kubernetes despliega. Tu parte termina en el push y el PR; desde el merge, todo es automático y deja rastro: commit → check → imagen :SHA → rollout.
Trampa
Creer que el merge es el deploy. El merge solo cambia main: a producción llega la imagen con el SHA de ese commit, cuando CD la construye, la sube y el manifest la apunta. Y abrir el PR con conflictos: los workflows de pull_request no corren hasta que los resuelvas, así que no hay checks que mirar.
En el curso: M10 · CI/CD para modelos: la pirámide de tests M10 · El validation gate: cuándo un modelo puede reemplazar al actual M5 · Registries y tags inmutables C01 · Archivos de referencia
Qué vas a provocar: Recorrer la cadena con un remoto local: rama, commit, push, revisión desde otra copia, merge, y el SHA que sería el tag de la imagen.
Necesitas: git 2.28 o más nuevo (git --version) y tu nombre y email configurados (git config --global user.name y user.email). Todo es local: no necesitas cuenta de GitHub.
git init --bare -b main ~/lab-git-remote.git
mkdir ~/lab-git && cd ~/lab-git && git init -b main
mkdir -p gateway/app
printf 'INFER_TIMEOUT_S = 0.15\n' > gateway/app/main.py
git add . && git commit -m "gateway: primera versión"
git remote add origin ~/lab-git-remote.git
git push -u origin mainQué deberías ver: Dos Initialized empty Git repository (el remoto y tu repo), el commit [main (root-commit) <sha>] y el push: * [new branch] main -> main y branch 'main' set up to track 'origin/main'. La carpeta lab-git-remote.git hace el papel de GitHub: con --bare guarda solo la historia, sin archivos para editar, como un repo en GitHub.
git switch -c feat/gateway-timeout
printf 'INFER_TIMEOUT_S = 0.12\n' > gateway/app/main.py
git add gateway/app/main.py
git diff --staged --stat
git commit -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"Qué deberías ver: Switched to a new branch 'feat/gateway-timeout', el resumen gateway/app/main.py | 2 +- (lo que entra al commit) y [feat/gateway-timeout <sha>] gateway: bajar INFER_TIMEOUT_S a 120 ms.
git push -u origin feat/gateway-timeoutQué deberías ver: * [new branch] feat/gateway-timeout -> feat/gateway-timeout y branch 'feat/gateway-timeout' set up to track 'origin/feat/gateway-timeout'. Contra GitHub, aquí aparecerían líneas remote: con el link para abrir el PR.
git clone ~/lab-git-remote.git ~/lab-git-equipo
cd ~/lab-git-equipo
git switch feat/gateway-timeout
git diff main...feat/gateway-timeoutQué deberías ver: Cloning into … y done. git switch encuentra la rama en el remoto y la crea conectada (set up to track). El diff muestra -INFER_TIMEOUT_S = 0.15 y +INFER_TIMEOUT_S = 0.12: lo mismo que la pestaña Files changed del PR, que también compara contra el punto donde nació la rama.
git switch main
git merge --no-ff --no-edit feat/gateway-timeout
git pushQué deberías ver: Merge made by the 'ort' strategy. y el push de main (<sha>..<sha> main -> main). --no-ff crea un commit de merge aunque bastara con mover la etiqueta, como el botón Create a merge commit de GitHub; --no-edit acepta el mensaje sin abrir el editor.
cd ~/lab-git
git switch main
git pull
git log --oneline --graph
git rev-parse --short=7 HEADQué deberías ver: Ojo: git switch main dice Your branch is up to date with 'origin/main'., y es falso: es según tu último fetch. git pull trae el merge (Updating <sha>..<sha>, Fast-forward: tu main solo avanza hasta el merge). El log dibuja el merge con sus dos padres, y rev-parse imprime 7 caracteres: el tag que tendría la imagen.
git branch -d feat/gateway-timeout
git push origin --delete feat/gateway-timeoutQué deberías ver: Deleted branch feat/gateway-timeout (was <sha>). y, en el remoto, - [deleted] feat/gateway-timeout. git branch -d solo borra una rama si sus commits ya están a salvo en la rama donde estás o en su rama remota; si no, se niega con not fully merged.
cd ~ && rm -rf ~/lab-git ~/lab-git-remote.git ~/lab-git-equipoQué deberías ver: No imprime nada: borra tu repo, el "GitHub" local y la copia de la otra persona.
Cómo arreglarlo y confirmarlo
Si git push a secas falla con has no upstream branch, la rama todavía no está conectada: git push -u origin {rama}. Si git dice que estás al día pero no ves el merge, corre git pull (o git fetch): el "up to date" es según tu último fetch.
Probado de verdad en una carpeta de prueba (git 2.50.1). Los SHAs y las rutas cambian en cada máquina.
¿Qué guarda git en realidad, qué es una rama, qué es HEAD y por qué hay un paso entre editar y commitear?
Sintaxis con variables
git init -b ramaCrea la carpeta .git: ahí vive el repositorio, con todos los commits (las fotos del proyecto), las ramas (etiquetas que avanzan con cada commit) y los tags (etiquetas fijas). -b main le pone nombre a la primera rama; sin eso, git usa init.defaultBranch y, si no está configurada, master (Git 3.0, todavía sin fecha, la cambiará a main). GitHub llama main a la rama por defecto.
git cat-file -p refMuestra por dentro un objeto (cada cosa que git guarda: un commit, una carpeta o un archivo). Un commit tiene su tree (la foto de los archivos), su parent (el commit anterior), autor y committer (quién lo escribió y quién lo guardó, con sus fechas) y el mensaje.
git branch rama git rev-parse rama main
Crear una rama escribe un archivo con un SHA (el nombre de 40 caracteres de un commit): no copia ningún archivo del proyecto. Recién creada, apunta al commit donde estás (HEAD, el "estás aquí" de git); si estás en main, al mismo que main.
git switch --detach refPone en disco una foto vieja sin mover ninguna rama: quedas en detached HEAD (HEAD apunta a un commit, no a una rama). Sirve para mirar o probar; git switch - te devuelve a tu rama.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| rama | nombre de la rama | main, feat/gateway-timeout |
| ref | cualquier nombre de un commit: HEAD, HEAD~1, una rama o un SHA | HEAD |
| archivo en .git/refs/heads con el SHA del commit al que apunta (formato files, el default) | wc -c .git/refs/heads/main → 41 |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| repositorio (.git) | La carpeta oculta donde git guarda commits, ramas y configuración. Si la borras, borras la historia. | ~/lab-git/.git |
| commit | Una foto de todos los archivos del proyecto en un momento, con un mensaje. Guarda la foto entera, no "el diff" (solo las líneas que cambiaron). | gateway: bajar INFER_TIMEOUT_S a 120 ms |
| tree (árbol) | La lista de carpetas y archivos de la foto, con el SHA del contenido de cada uno. | 040000 tree … gateway |
| blob | El contenido de un archivo, guardado una sola vez. Si no cambia, el commit siguiente reusa el mismo. | preprocess.py: el mismo blob 6d75488 en los dos commits |
| parent (padre) | El commit anterior. Por eso la historia es una cadena que va hacia atrás. | parent 9798182… |
| SHA | El nombre del commit: un hash (una huella que git calcula) de su contenido. Si cambia cualquier cosa (un archivo, el mensaje, el padre), cambia el SHA. | 3387071 |
| rama (branch) | Una etiqueta con el SHA de un commit. Cada commit que haces estando en ella la mueve hacia adelante. No es una copia del proyecto. | .git/refs/heads/feat/gateway-timeout |
| HEAD | El "estás aquí": casi siempre apunta a una rama, y la rama a un commit. | ref: refs/heads/main |
| detached HEAD | HEAD apunta directo a un commit. Si commiteas ahí, ninguna rama guarda esos commits. | HEAD detached at 315a4b2 |
| working tree | Tus archivos en disco, los que editas. Cambiar de rama los reescribe. | gateway/app/main.py |
| staging (index) | La zona intermedia: la versión de cada archivo que entrará en el próximo commit. Te deja commitear solo una parte de lo que cambiaste. | git add gateway/app/main.py |
| remoto (origin) | Otra copia del repo, casi siempre en GitHub. origin es su nombre corto. | git remote -v |
| origin/main | Rama de seguimiento: tu copia local de dónde estaba main en el remoto la última vez que hablaste con él. | git log origin/main |
| git switch · git restore | Desde Git 2.23 se reparten los dos trabajos del viejo git checkout: switch cambia de rama, restore recupera archivos. | git checkout main = git switch main |
Working tree
tus archivos en disco: donde editas
Míralo con git status · git diff
Staging (index)
la versión de cada archivo que entra en el próximo commit
Míralo con git diff --staged
Repositorio local (.git)
commits, ramas y tags en tu máquina
Míralo con git log
Remoto (origin, en GitHub)
la copia que comparte el equipo
Míralo con git fetch y después git log origin/main
git add ruta
copia la versión actual del archivo al staging
git commit -m "…"
guarda la foto del staging como commit nuevo y mueve la rama
git push
sube tus commits y mueve la rama del remoto; solo acepta avanzar
git fetch
trae commits y mueve solo origin/…; tus ramas y tus archivos no cambian
git pull
fetch y después junta lo que llegó (merge o rebase, sección Ramas): mueve tu rama y actualiza staging y archivos (pasa por repositorio local)
git switch rama
pone en staging y en disco la foto de otra rama (pasa por staging)
git restore ruta
descarta lo que cambiaste en disco: vuelve a la versión del staging
git restore --staged ruta
saca la ruta del staging (vuelve a la versión de HEAD); tu archivo no cambia
De arriba abajo: «hacia la derecha» es hacia abajo.
Working tree
tus archivos en disco: donde editas
Míralo con git status · git diff
git add ruta
copia la versión actual del archivo al staging
git pull
llega desde: remoto · pasa por: repositorio local
fetch y después junta lo que llegó (merge o rebase, sección Ramas): mueve tu rama y actualiza staging y archivos
git switch rama
llega desde: repositorio local · pasa por: staging
pone en staging y en disco la foto de otra rama
git restore ruta
descarta lo que cambiaste en disco: vuelve a la versión del staging
Staging (index)
la versión de cada archivo que entra en el próximo commit
Míralo con git diff --staged
git commit -m "…"
guarda la foto del staging como commit nuevo y mueve la rama
git restore --staged ruta
saca la ruta del staging (vuelve a la versión de HEAD); tu archivo no cambia
Repositorio local (.git)
commits, ramas y tags en tu máquina
Míralo con git log
git push
sube tus commits y mueve la rama del remoto; solo acepta avanzar
git fetch
trae commits y mueve solo origin/…; tus ramas y tus archivos no cambian
Remoto (origin, en GitHub)
la copia que comparte el equipo
Míralo con git fetch y después git log origin/main
en tu máquinacruza a la red (GitHub)pasa por esa zona
Lo que no commiteaste no es de ninguna rama: git switch lo lleva contigo si no choca con la otra rama, y se niega si lo pisaría (Your local changes to the following files would be overwritten by checkout).
$ cat .git/HEAD
ref: refs/heads/feat/gateway-timeout
$ cat .git/refs/heads/feat/gateway-timeout
338707141fa107b7131a8c16626d6cc4e9ff1305
$ git cat-file -p HEAD
tree d6cd4c098b8ee7ac214109095879486b26e545e8
parent 979818285678056fc3d1861d7d424c2a27aae823
author Ana Torres <ana@example.com> 1792645892 -0600
committer Ana Torres <ana@example.com> 1792645892 -0600
gateway: bajar INFER_TIMEOUT_S a 120 ms
$ git cat-file -p HEAD:gateway/app
100644 blob 5123940f72bde62371315600b516f9e0efcff01c __init__.py
100644 blob 97f6b302a8f484d4a308fa1895a73a498bc9ed63 labels.json
100644 blob a3820d9939b821509addc20339c44b35dee89268 main.py
100644 blob 6d75488cb6c744ecde54335cae4b411181ebd5bc preprocess.py
$ git cat-file -p HEAD~1:gateway/app
100644 blob 5123940f72bde62371315600b516f9e0efcff01c __init__.py
100644 blob 97f6b302a8f484d4a308fa1895a73a498bc9ed63 labels.json
100644 blob e2709cc8a36ffc7b1ccd456f8fb2d857e646bbcb main.py
100644 blob 6d75488cb6c744ecde54335cae4b411181ebd5bc preprocess.py.git/HEAD no tiene un SHA: dice en qué rama estás. Y la rama es un archivo con un SHA (3387071…). Eso es todo lo que es una rama.tree (la foto del proyecto) y a su parent (9798182, el commit anterior). El primer commit de un repo no tiene parent; un merge tiene dos.main.py tiene otro blob (a3820d9 contra e2709cc). labels.json y preprocess.py reusan el mismo: la foto es completa, pero lo que no cambió no se copia.commit 244 (el tamaño). Cambia el mensaje, la fecha o el padre y el SHA cambia: por eso amend (rehacer el último commit) y rebase (copiar commits sobre otra base) crean commits nuevos..git/packed-refs (pasa con las ramas remotas tras un clone, o tras un git gc, la limpieza que git corre de vez en cuando). git rev-parse {rama} funciona siempre.Cómo recordarla
Un commit es una foto del proyecto, con su padre y un mensaje; una rama es una etiqueta que avanza sola con cada commit que haces en ella; HEAD es el "estás aquí". Entre tu disco y la historia está el staging: ahí eliges qué entra en la foto.
Trampa
Pensar que una rama es una copia del proyecto: crear una escribe 41 bytes, y cambiar de rama reescribe tus archivos en disco. Lo que no commiteaste no es de ninguna rama: viaja contigo al cambiar, y git se niega si lo pisaría. Y creer que un commit guarda "el diff": guarda la foto entera (lo que no cambió se reusa, no se copia).
En el curso: M10 · Reproducibilidad: los cinco ingredientes M9 · Versionado de datos y snapshots inmutables M5 · Registries y tags inmutables
Qué vas a provocar: Abrir .git y ver que un commit es una foto con padre, que una rama es un archivo de 41 bytes y que HEAD dice dónde estás.
Necesitas: git 2.28 o más nuevo (git --version) y tu nombre y email configurados (git config --global user.name y user.email). Todo es local: no necesitas cuenta de GitHub.
mkdir ~/lab-git && cd ~/lab-git && git init -b main
mkdir -p gateway/app
printf 'INFER_TIMEOUT_S = 0.15\n' > gateway/app/main.py
git add . && git commit -m "gateway: primera versión"Qué deberías ver: Initialized empty Git repository in /Users/<tú>/lab-git/.git/ y [main (root-commit) <sha>] gateway: primera versión.
cat .git/HEAD
cat .git/refs/heads/main
git cat-file -p HEAD
git cat-file -p 'HEAD^{tree}'Qué deberías ver: ref: refs/heads/main; el SHA completo de 40 caracteres (la rama es solo eso); el commit por dentro: tree, author, committer y el mensaje, sin parent porque es el primero; y la foto: 040000 tree <sha> gateway (cada carpeta es otro tree y cada archivo, un blob).
git branch feat/gateway-timeout
git branch -v
cat .git/refs/heads/feat/gateway-timeoutQué deberías ver: Las dos ramas con el mismo SHA corto y el mismo mensaje, y el archivo de la rama nueva con el mismo SHA que main: crear una rama no copió nada.
git switch feat/gateway-timeout
printf 'INFER_TIMEOUT_S = 0.12\n' > gateway/app/main.py
git commit -am "gateway: bajar INFER_TIMEOUT_S a 120 ms"
git branch -vQué deberías ver: Switched to branch 'feat/gateway-timeout', el commit nuevo, y en branch -v la rama nueva en otro SHA mientras main sigue donde estaba: solo se movió la etiqueta donde está HEAD. (-a agrega los cambios de archivos que git ya seguía.)
git cat-file -p HEADQué deberías ver: Ahora aparece parent <SHA de main>: el commit nuevo apunta al anterior.
git switch main
cat gateway/app/main.py
git switch -
cat gateway/app/main.pyQué deberías ver: INFER_TIMEOUT_S = 0.15 en main y INFER_TIMEOUT_S = 0.12 en la rama: cambiar de rama reescribió el archivo en disco. git switch - vuelve a la rama anterior.
git switch --detach HEAD~1
cat .git/HEAD
git statusQué deberías ver: HEAD is now at <sha> gateway: primera versión; .git/HEAD ahora tiene un SHA en vez de ref: …; y git status dice HEAD detached at <sha>. Sin --detach, git switch <sha> se niega: fatal: a branch is expected, got commit '<sha>', con un hint que sugiere --detach.
git switch -
git status -sbQué deberías ver: Previous HEAD position was <sha> gateway: primera versión y Switched to branch 'feat/gateway-timeout'; status -sb vuelve a mostrar ## feat/gateway-timeout.
cd ~ && rm -rf ~/lab-gitQué deberías ver: No imprime nada.
Cómo arreglarlo y confirmarlo
Si git switch se niega por cambios locales, commitéalos, guárdalos con git stash o descártalos con git restore (sección Deshacer). Si commiteaste en detached HEAD, git switch -c {rama} antes de irte los deja a salvo en una rama.
Probado de verdad en una carpeta de prueba (git 2.50.1). Los SHAs y las rutas cambian en cada máquina.
¿Qué comandos usas todos los días, qué toca cada uno (tus archivos, tu repo o el remoto) y qué haces cuando push o pull se niegan?
Sintaxis con variables
git clone url cd carpeta
Copia el repo con toda su historia a una carpeta nueva, registra el remoto (la copia en GitHub) como origin y te deja en la rama por defecto (casi siempre main), conectada con origin/main: tu copia local del main de GitHub. La URL puede ser SSH o HTTPS, dos formas de conectarte; cómo autenticarte está en la sección GitHub.
git status git diff git add ruta git diff --staged git commit -m "mensaje"
El ciclo de cada commit (cada foto que guardas): mira qué cambió, revisa el diff (las líneas que cambiaron), elige con add y revisa lo elegido antes de guardar. git add -p te deja elegir pedazos de un archivo.
git fetch git status -sb git pull --rebase git push
Sincronizar. fetch baja lo nuevo y solo mueve origin/…: tus archivos no cambian. pull = fetch + juntar con tu rama; --rebase pone tus commits locales encima de los del remoto, sin un commit de merge (uno extra que junta las dos historias). push sube lo tuyo; la primera vez de una rama, git push -u origin rama.
git config --global pull.ff onlyDecide una vez qué hace git pull cuando las ramas divergieron (cada una tiene commits que la otra no). Con pull.ff only solo avanza si no hay nada que mezclar y, si divergieron, se niega y eliges tú con git pull --rebase. Sin ninguna config también se niega (desde Git 2.33.1): fatal: Need to specify how to reconcile divergent branches.
git check-ignore -v ruta git rm --cached ruta
check-ignore -v dice qué línea del .gitignore (la lista de patrones que git ignora) ignora un archivo. El .gitignore no afecta archivos que git ya sigue: para dejar de seguir uno sin borrarlo del disco, git rm --cached y un commit.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| url | dirección del repo | git@github.com:equipo/classifier.git (ilustrativo) |
| carpeta | la carpeta que crea clone: el nombre del repo sin .git | classifier |
| ruta | archivo o carpeta | gateway/app/main.py |
| mensaje | qué cambió, en una línea | preprocess: comentar RESIZE_SHORT |
| rama | tu rama, la primera vez que la subes | feat/gateway-timeout |
| los commits que están en B y no en A | HEAD..origin/main = lo del remoto que todavía no tienes (HEAD es donde estás) | |
| en diff: lo que cambió B desde su merge-base con A (el último commit que tienen en común) | git diff main...feat/gateway-timeout = el diff del PR (pull request) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| git clone | Copia el repo entero, con toda la historia, y crea origin. | git clone git@github.com:equipo/classifier.git |
| git status | Qué cambió, qué está en staging y cómo vas contra tu foto del remoto. No le pregunta nada al remoto. | git status -sb → ## main...origin/main [behind 1] |
| git add | Copia la versión actual de un archivo al staging. -p elige pedazos; un punto agrega todo lo de la carpeta (mira antes). | git add -p gateway/app/main.py |
| git commit -m | Guarda la foto del staging con un mensaje. -a suma los cambios de archivos que git ya seguía, no los nuevos. | git commit -m "gateway: tope de top_k" |
| git diff | Lo que cambiaste en disco y todavía no está en staging. | -INFER_TIMEOUT_S = 0.15 / +INFER_TIMEOUT_S = 0.12 |
| git diff --staged | Lo que está en staging contra el último commit: exactamente lo que vas a commitear. | git diff --staged --stat |
| git log | La historia. --oneline: una línea por commit; --graph: dibuja las ramas; -p: con el diff de cada uno. | git log --oneline --graph -n 10 |
| git fetch | Baja commits y ramas del remoto y actualiza origin/…; no toca tus ramas ni tus archivos. | main -> origin/main |
| git pull | fetch + juntar con tu rama lo que llegó a su rama remota (upstream): merge, rebase o solo avanzar, según tu config. | Updating 9798182..27bd258 · Fast-forward |
| fast-forward | Avanzar la etiqueta de tu rama hasta el commit del remoto, sin crear nada: no había nada que mezclar. | lo normal en main, si nunca commiteas ahí |
| git push | Sube tus commits y mueve la rama del remoto. Solo acepta avanzar: si el remoto tiene algo que tú no, lo rechaza. | ! [rejected] … (non-fast-forward) |
| upstream (-u) | La rama remota a la que está conectada la tuya. La usan pull, push y status. | git push -u origin feat/gateway-timeout |
| .gitignore | Patrones de archivos que git no ofrece agregar: venvs, caches, secretos, pesos. | *.onnx · .env · __pycache__/ |
| git rm --cached | Deja de seguir un archivo sin borrarlo del disco; el .gitignore evita que vuelva a entrar. | rm 'notas.log' |
1fetch: solo se mueve origin/main
git fetch
C3 lo subió otra persona. fetch lo trae y mueve origin/main; main y tus archivos siguen en C2. git status -sb ahora dice [behind 1].
2pull sin divergencia: fast-forward
git pull
No había nada que mezclar: la etiqueta main avanza hasta C3 y tus archivos se actualizan. Ningún commit nuevo.
3ramas divergentes en tu rama de PR
git pull --rebase
C3 (la sugerencia de la review) y tu C4 nacieron de C2: divergieron, y push se rechaza (non-fast-forward). --rebase copia C4 encima de C3 como C4', con otro SHA (otro nombre de commit); C4 queda solo en el reflog (el historial local de HEAD).
4ahora sí: el remoto avanza
git push
C4' desciende de C3: para el remoto es avanzar, así que acepta el push.
Arriba, lo más nuevo; cada línea une un commit con su padre.commitcommit nuevosolo en el reflograma localrama remota (origin/…)HEADestás aquídónde estaba antes
# .gitignore en la raíz del repo
# Python: se regenera solo, y un venv de macOS no sirve en la máquina Linux de CI
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
# Secretos locales: nunca en git (el historial es para siempre)
.env
*.pem
# Pesos y engines: los generan los scripts de model/ y viven fuera de git
*.pt
*.onnx
*.plan__pycache__/) solo ignora carpetas. Un patrón sin barra en medio vale en cualquier subcarpeta: *.onnx ignora también model/classifier.onnx..env y *.pem: un secreto commiteado queda en la historia aunque lo borres en el commit siguiente. Si pasó, rótalo primero: crea uno nuevo e invalida el viejo (M15)..gitignore no saca archivos que git ya sigue: para eso, git rm --cached {ruta} y un commit. Lo de tu máquina (.DS_Store, .vscode/) va mejor en tu ignore global, ~/.config/git/ignore.git status -s --ignored marca con !! lo que el .gitignore esconde (sin -s, lo lista bajo Ignored files), y git check-ignore -v {ruta} dice qué línea lo ignora.Cómo recordarla
fetch mira, pull trae, push entrega. fetch solo mueve origin/…; pull además mueve tu rama y tus archivos; push solo acepta avanzar. Antes de cada commit: git status y git diff --staged.
Trampa
git add . sin mirar: entran el .env, un classifier.onnx de cientos de MB o un __pycache__. Revisa git status y git diff --staged antes de cada commit. Y cuando un push se rechaza, no fuerces: git fetch, mira qué llegó y junta con git pull --rebase.
En el curso: M9 · Versionado de datos y snapshots inmutables M15 · Secretos y credenciales M10 · CI/CD para modelos: la pirámide de tests
Qué vas a provocar: Trabajar con otra persona sobre un remoto local: ver que fetch no toca tus archivos, provocar un push rechazado y un pull que se niega, resolverlo con pull --rebase y, al final, armar un .gitignore.
Necesitas: git 2.28 o más nuevo y tu nombre y email configurados. Todo es local: el "GitHub" es la carpeta ~/lab-git-remote.git y la otra persona del equipo trabaja en ~/lab-git-equipo.
git init --bare -b main ~/lab-git-remote.git
git clone ~/lab-git-remote.git ~/lab-git-equipo
cd ~/lab-git-equipo
git config user.name "Leo Ruiz"
git config user.email "leo@example.com"
mkdir -p gateway/app
printf 'INFER_TIMEOUT_S = 0.15\n' > gateway/app/main.py
printf 'RESIZE_SHORT = 256\n' > gateway/app/preprocess.py
git add . && git commit -m "gateway: primera versión" && git push -u origin mainQué deberías ver: Initialized empty Git repository in …/lab-git-remote.git/, el aviso warning: You appear to have cloned an empty repository. (el remoto todavía no tiene commits), el commit y * [new branch] main -> main. Leo Ruiz es la otra persona del equipo: user.name y user.email solo cambian dentro de esa copia. --bare crea un repo solo con la historia, sin archivos para editar, como el de GitHub.
git clone ~/lab-git-remote.git ~/lab-git
cd ~/lab-git
git config pull.ff only
git switch -c feat/gateway-timeout
printf 'INFER_TIMEOUT_S = 0.12\n' > gateway/app/main.py
git commit -am "gateway: bajar INFER_TIMEOUT_S a 120 ms"
git push -u origin feat/gateway-timeoutQué deberías ver: Cloning into … y done.; Switched to a new branch 'feat/gateway-timeout'; el commit; y el push con * [new branch] y set up to track. pull.ff only queda solo en este repo (sin --global).
cd ~/lab-git-equipo
git fetch
git switch feat/gateway-timeout
printf 'MAX_TOP_K = 20\n' >> gateway/app/main.py
git commit -am "review: tope de top_k" && git pushQué deberías ver: El fetch trae tu rama (* [new branch] feat/gateway-timeout -> origin/feat/gateway-timeout), git switch la crea conectada (set up to track), y el push sube review: tope de top_k. En GitHub pasa lo mismo cuando alguien aplica una sugerencia de la review con Commit suggestion: un commit nuevo en tu rama del remoto.
cd ~/lab-git
git status -sb
git fetch
git status -sb
git log --oneline HEAD..origin/feat/gateway-timeoutQué deberías ver: El primer status dice ## feat/gateway-timeout...origin/feat/gateway-timeout, sin behind: status no le pregunta al remoto. Después del fetch dice [behind 1] y el log muestra review: tope de top_k. Tus archivos no cambiaron.
printf 'RESIZE_SHORT = 256 # igual que en training\n' > gateway/app/preprocess.py
git commit -am "preprocess: comentar RESIZE_SHORT"
git status -sb
git pushQué deberías ver: [ahead 1, behind 1]: divergieron. El push se rechaza: ! [rejected] feat/gateway-timeout -> feat/gateway-timeout (non-fast-forward), con un hint que sugiere git pull. Sin el fetch del paso anterior, diría (fetch first).
git pullQué deberías ver: Se niega: fatal: Not possible to fast-forward, aborting., después de un hint que sugiere merge o rebase. Tu rama y tus archivos no cambiaron. Sin ninguna config (desde Git 2.33.1), el mensaje sería fatal: Need to specify how to reconcile divergent branches.
git pull --rebase
git log --oneline --graph
git pushQué deberías ver: Successfully rebased and updated refs/heads/feat/gateway-timeout. El log queda en línea recta, con tu commit encima del de la review y con otro SHA. El push ahora se acepta: <sha>..<sha> feat/gateway-timeout -> feat/gateway-timeout.
printf '.env\n__pycache__/\n*.onnx\n' > .gitignore
printf 'TOKEN=no-lo-subas\n' > .env
mkdir -p model && printf 'pesos' > model/classifier.onnx
git status -s
git check-ignore -v .env model/classifier.onnxQué deberías ver: status -s muestra solo ?? .gitignore: .env y model/ ni aparecen. check-ignore -v dice qué línea ignora cada uno: .gitignore:1:.env y .gitignore:3:*.onnx.
cd ~ && rm -rf ~/lab-git ~/lab-git-remote.git ~/lab-git-equipoQué deberías ver: No imprime nada.
Cómo arreglarlo y confirmarlo
Push rechazado: git fetch y git status -sb para ver qué llegó. Si divergieron en tu rama de PR, git pull --rebase y otra vez git push. Si un archivo que no querías está en tu último commit y no hiciste push: git rm --cached {ruta}, agrégalo al .gitignore y git commit --amend (rehace el último commit).
Probado de verdad en una carpeta de prueba (git 2.50.1). Los SHAs y las rutas cambian en cada máquina.
¿Cómo trabajas en paralelo sin pisarte con el equipo, cómo juntas las ramas y qué haces cuando git marca un conflicto?
Sintaxis con variables
git switch -c rama git switch rama git branch -d rama
Crear y cambiarte (-c), solo cambiarte, y borrar una rama ya mergeada (unida a otra). -d se niega si la rama tiene commits que no están ni en la rama donde estás ni en su rama remota (is not fully merged); -D la borra igual.
git merge rama git merge --no-ff --no-edit rama
Junta otra rama con la tuya. Si tu rama no avanzó, git solo mueve la etiqueta (fast-forward); si las dos avanzaron, crea un merge commit con dos padres. --no-ff fuerza el merge commit y --no-edit acepta el mensaje sin abrir el editor.
git rebase base
git push --force-with-leaseCopia tus commits encima de la punta de base, con SHAs nuevos (otros nombres de commit): la historia queda en línea recta. Si la rama ya estaba en el remoto, el push normal se rechaza; --force-with-lease la reemplaza solo si nadie empujó desde tu último fetch. Nunca en main.
git status git add ruta git commit --no-edit git merge --abort
Un conflicto: las dos ramas cambiaron distinto las mismas líneas (o líneas vecinas) y git no elige. status los marca both modified; editas el archivo, quitas las marcas, git add y commit. --abort te deja como antes del merge.
git rebase --continue git rebase --abort
En un rebase, el conflicto se resuelve igual (editar y git add), pero sigues con --continue, que abre el editor con el mensaje del commit. GIT_EDITOR=true git rebase --continue lo acepta tal cual.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| rama | rama a crear, cambiar, juntar o borrar | feat/gateway-timeout |
| base | la rama sobre la que pones tus commits | main (u origin/main, recién traída con fetch) |
| ruta | archivo en conflicto | gateway/app/main.py |
| el último commit que las dos ramas tienen en común (merge-base) | git merge-base main feat/gateway-timeout | |
| lo que cambió tu rama desde la base | 0.15 → 0.12 en INFER_TIMEOUT_S | |
| lo que cambió la otra rama desde la base | 0.15 → 0.18: un hotfix (arreglo urgente) que ya entró a main | |
| el archivo combinado; si los cambios no chocan, git lo arma solo | el main.py del merge commit |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| rama (branch) | Etiqueta que avanza con cada commit que haces en ella. Trabajar en una rama no toca a las demás. | feat/gateway-timeout |
| fast-forward | Juntar sin commit nuevo: la etiqueta solo avanza, porque la otra rama ya contenía todo lo tuyo. | Updating be403cc..d73cbdd · Fast-forward |
| merge commit | Commit con dos padres que junta dos historias que avanzaron por separado. | Merge made by the 'ort' strategy. |
| merge-base | El último commit en común. Git compara cada lado contra él (three-way merge). | git merge-base main HEAD |
| rebase | Rehace tus commits, uno por uno, encima de otra base. Cada uno sale con un SHA nuevo. | Successfully rebased and updated refs/heads/feat/gateway-timeout. |
| conflicto (conflict) | Las dos ramas cambiaron distinto las mismas líneas, o líneas vecinas. Git para y te pide decidir. | CONFLICT (content): Merge conflict in gateway/app/main.py |
| marcas de conflicto | <<<<<<< abre tu lado, ======= separa, >>>>>>> cierra el otro lado. Al resolver, las borras. | <<<<<<< HEAD … >>>>>>> main |
| UU · both modified | Archivo en conflicto sin resolver. Pasa a resuelto cuando haces git add. | UU gateway/app/main.py |
| git merge --abort · git rebase --abort | Cancelan y te dejan como antes de empezar. | cuando el conflicto es más grande de lo que esperabas |
| ours / theirs | En un merge, ours es tu rama y theirs la que entra. En un rebase se invierten: ours es la base y theirs, tu commit. | git restore --theirs gateway/app/main.py |
| git diff --check | Avisa si quedaron marcas de conflicto o espacios sobrantes. Pásalo antes del git add. | gateway/app/main.py:38: leftover conflict marker |
| merge.conflictStyle zdiff3 | Agrega a las marcas la versión de la base, tras |||||||: ves qué había antes de los dos cambios. Existe desde Git 2.35. | git config --global merge.conflictStyle zdiff3 |
| git push --force-with-lease | Reemplaza la rama remota solo si sigue donde la viste en tu último fetch. Para tu rama de PR (pull request), nunca para main. | + 941be23...8707e88 feat/gateway-timeout -> feat/gateway-timeout (forced update) |
| git branch -d · -D | Borra una rama local. -d solo si sus commits ya están en la rama donde estás o en su rama remota; -D, aunque no lo estén. | error: the branch 'feat/x' is not fully merged |
1Fast-forward: main no tenía commits nuevos
git merge feat/gateway-timeout
main es ancestro de la rama: git solo avanza la etiqueta. No hay commit nuevo y la historia queda en línea recta.
2Merge commit: las dos ramas avanzaron
git merge feat/gateway-timeout
M tiene dos padres: C5 (el primero, el main de antes) y C4 (la rama). Si C3 o C4 tocaron las mismas líneas que C5, aquí aparece el conflicto.
3Rebase, parado en la rama: tus commits, copiados encima
git rebase main
C3' y C4' traen los mismos cambios con SHAs nuevos; C3 y C4 quedan solo en el reflog. Ahora juntar la rama con main es un fast-forward. Si la rama ya estaba en el remoto: git push --force-with-lease.
4Squash: un solo commit nuevo en main
Squash and merge en GitHub (≈ git merge --squash y git commit)
S trae juntos los cambios de C3 y C4, con un solo padre. Los commits de la rama no entran a main: bórrala después del merge. El tag de la imagen sale del SHA de S.
Arriba, lo más nuevo; cada línea une un commit con su padre.commitcommit nuevosolo en el reflograma localrama remota (origin/…)HEADestás aquídónde estaba antes
$ git config --global merge.conflictStyle zdiff3
$ git merge main
Auto-merging gateway/app/main.py
CONFLICT (content): Merge conflict in gateway/app/main.py
Automatic merge failed; fix conflicts and then commit the result.
$ git status -s
UU gateway/app/main.py
$ sed -n 38,44p gateway/app/main.py
<<<<<<< HEAD
INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.12")) # 120 ms: el resto del p99 ≤ 200 ms queda para el preprocess
||||||| 0c52e4b
INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.15")) # 150 ms para Triton dentro del p99 ≤ 200 ms
=======
INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.18")) # hotfix: 504 en el pico con 150 ms
>>>>>>> main
# … dejas una sola línea y borras las marcas …
$ git diff --check
$ git add gateway/app/main.py
$ git commit --no-edit
[feat/gateway-timeout d7e8c0c] Merge branch 'main' into feat/gateway-timeoutfeat/gateway-timeout y traes main a tu rama para resolver el conflicto antes de que lo vea la review. Git juntó solo todo lo demás; paró en la única línea que tocaron los dos.<<<<<<< HEAD y ||||||| está tu lado; después de |||||||, la base (0c52e4b, el último commit en común); entre ======= y >>>>>>> main, el otro lado. Sin zdiff3, la parte de la base no aparece.git diff --check sin salida significa que no quedaron marcas. Git acepta un git add con marcas adentro, así que este chequeo es tuyo.git push, el PR se actualiza y el CI (las pruebas automáticas) vuelve a correr sobre el resultado.Cómo recordarla
Merge junta, rebase reescribe. Merge agrega un commit con dos padres (o solo avanza la etiqueta: fast-forward); rebase copia tus commits encima de otra base, con SHAs nuevos. Regla de oro: no hagas rebase de commits que otras personas ya tienen.
Trampa
En un rebase, ours y theirs se invierten: HEAD (ours) es la base y el otro lado es tu commit, así que git restore --theirs en medio de un rebase se queda con lo tuyo. Y después de un rebase de tu rama ya subida, el push rechazado sugiere git pull: eso mezclaría la versión vieja. Lo que va es git push --force-with-lease.
En el curso: M10 · CI/CD para modelos: la pirámide de tests M10 · El validation gate: cuándo un modelo puede reemplazar al actual C01 · Archivos de referencia
Qué vas a provocar: Provocar un conflicto real en la misma línea, resolverlo con merge, repetirlo con rebase para ver las marcas invertidas, y terminar con un fast-forward.
Necesitas: git 2.28 o más nuevo (2.35 para zdiff3; git --version) y tu nombre y email configurados (git config --global user.name y user.email). Todo es local: no necesitas cuenta de GitHub.
mkdir ~/lab-git && cd ~/lab-git && git init -b main
mkdir -p gateway/app
printf 'INFER_TIMEOUT_S = 0.15 # 150 ms\nMAX_TOP_K = 20\n' > gateway/app/main.py
git add . && git commit -m "gateway: primera versión"Qué deberías ver: Initialized empty Git repository in /Users/<tú>/lab-git/.git/ y [main (root-commit) <sha>] gateway: primera versión, con 2 insertions(+).
git switch -c feat/gateway-timeout
printf 'INFER_TIMEOUT_S = 0.12 # 120 ms: deja margen al preprocess\nMAX_TOP_K = 20\n' > gateway/app/main.py
git commit -am "gateway: bajar INFER_TIMEOUT_S a 120 ms"
git switch main
printf 'INFER_TIMEOUT_S = 0.18 # 180 ms: hotfix por 504 en el pico\nMAX_TOP_K = 20\n' > gateway/app/main.py
git commit -am "hotfix: subir INFER_TIMEOUT_S a 180 ms"
git log --oneline --graph --allQué deberías ver: Dos commits, uno en cada rama, y el log dibuja la bifurcación: tu commit, el hotfix en otra columna (|) y la primera versión abajo, donde se separan.
git switch feat/gateway-timeout
git merge mainQué deberías ver: Auto-merging gateway/app/main.py, CONFLICT (content): Merge conflict in gateway/app/main.py y Automatic merge failed; fix conflicts and then commit the result.
git status -s
cat gateway/app/main.py
git diff --checkQué deberías ver: UU gateway/app/main.py. El archivo tiene <<<<<<< HEAD, tu línea de 0.12, =======, la línea de 0.18 y >>>>>>> main; MAX_TOP_K = 20 quedó fuera de las marcas porque nadie la tocó. diff --check lista cada marca: gateway/app/main.py:1: leftover conflict marker, y lo mismo en las líneas 3 y 5. Si ya configuraste merge.conflictStyle zdiff3, verás además un bloque ||||||| con la línea de la base, y una marca más.
printf 'INFER_TIMEOUT_S = 0.12 # 120 ms: el deadline nuevo evita los 504\nMAX_TOP_K = 20\n' > gateway/app/main.py
git diff --check
git add gateway/app/main.py
git commit --no-edit
git log --oneline --graphQué deberías ver: diff --check ya no imprime nada. El commit es [feat/gateway-timeout <sha>] Merge branch 'main' into feat/gateway-timeout y el log muestra el merge con sus dos padres.
git reset --hard ORIG_HEAD
git rebase mainQué deberías ver: HEAD is now at <sha> gateway: bajar INFER_TIMEOUT_S a 120 ms (ORIG_HEAD es donde estabas antes del merge: lo deshiciste). El rebase choca en la misma línea: CONFLICT (content), error: could not apply <sha>… y un hint con --continue, --skip y --abort.
cat gateway/app/main.pyQué deberías ver: Las marcas invertidas: después de <<<<<<< HEAD viene el 0.18 de main, y tu 0.12 va al final, junto a >>>>>>> <sha> (gateway: bajar INFER_TIMEOUT_S a 120 ms). En un rebase, HEAD es la base y "el otro lado" es tu commit. (Con zdiff3, la base aparece en medio, tras |||||||.)
printf 'INFER_TIMEOUT_S = 0.12 # 120 ms: el deadline nuevo evita los 504\nMAX_TOP_K = 20\n' > gateway/app/main.py
git add gateway/app/main.py
GIT_EDITOR=true git rebase --continue
git log --oneline --graph --allQué deberías ver: Successfully rebased and updated refs/heads/feat/gateway-timeout. El log es una línea recta: tu commit (con otro SHA) encima del hotfix. Sin GIT_EDITOR=true se abriría tu editor con el mensaje; si es vim, sales con :wq y Enter.
git switch main
git merge feat/gateway-timeout
git branch -d feat/gateway-timeoutQué deberías ver: Updating <sha>..<sha>, Fast-forward: después del rebase, main solo avanza la etiqueta. Y Deleted branch feat/gateway-timeout (was <sha>).
cd ~ && rm -rf ~/lab-gitQué deberías ver: No imprime nada.
Cómo arreglarlo y confirmarlo
Si un conflicto te supera: git merge --abort (o git rebase --abort) y vuelves a donde estabas. Para entenderlo mejor, git config --global merge.conflictStyle zdiff3 y repite: las marcas traen también la versión de la base.
Probado de verdad en una carpeta de prueba (git 2.50.1). Los SHAs y las rutas cambian en cada máquina.
¿Cómo deshaces cada tipo de error, de un archivo sin guardar a un commit que ya está en main, y cómo recuperas un commit que creías perdido?
Sintaxis con variables
git restore ruta
git restore --staged rutaPara archivos. Sin opciones, descarta lo que cambiaste en disco (vuelve a la versión del staging: la de tu último git add o, si no hiciste add, la del último commit): no tiene deshacer. Con --staged, saca el archivo del staging y tu disco no cambia. Reemplazan a git checkout -- ruta y a git reset ruta.
git commit --amend --no-edit
Rehace el último commit con lo que hay en staging (con -m "…", también cambia el mensaje). Sale un commit nuevo, con otro SHA: úsalo solo si no hiciste push.
git reset modo HEAD~n
Mueve la rama n commits atrás. --soft: los cambios quedan en staging; --mixed (el default): quedan en disco, fuera del staging; --hard: se borran de staging y disco, junto con lo que no habías commiteado. Los commits siguen en el reflog (el historial local de HEAD).
git revert sha
git revert -m 1 sha_mergeCrea un commit nuevo que deshace otro. No reescribe nada: es el camino después de un push, y el rollback (volver a la versión anterior) por PR en main. Para revertir un merge commit, -m 1 dice que la línea principal es su primer padre.
git stash push -u -m "nota" git stash list git stash pop
Guarda tus cambios sin commit en una pila (el stash) y deja tu disco limpio. -u incluye también los archivos nuevos que git todavía no sigue (untracked); sin él, quedan afuera. pop los devuelve y borra la entrada; si hay conflicto, la entrada se queda.
git reflog
git branch rescate HEAD@{n}El reflog lista dónde estuvo HEAD. Un commit "perdido" tras un reset o un rebase sigue ahí (al menos 30 días por defecto): ponle una rama encima y queda a salvo.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| ruta | archivo a recuperar o a sacar del staging | gateway/app/main.py |
| modo | --soft, --mixed (el default) o --hard | --soft para rehacer el último commit |
| n | cuántos commits atrás (HEAD~n) o cuántos movimientos atrás en el reflog (HEAD@{n}) | 1 |
| sha | SHA del commit a revertir; el corto alcanza | c5aa999 |
| sha_merge | SHA de un merge commit, como el que deja el botón de un PR | cd07ebf: Merge pull request #42 |
| nota | para qué guardaste esos cambios | probar 100 ms |
| rescate | nombre de la rama nueva que apunta al commit recuperado | rescate |
| tiempo mínimo para recuperar un commit que quedó fuera de toda rama: lo que dura su línea en el reflog (gc.reflogExpireUnreachable) | 30 días por defecto; después, git gc (la limpieza automática de git) puede borrarlo |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| git restore ruta | Descarta los cambios en disco de ese archivo. No queda copia en ningún lado. | volver main.py a la versión del staging |
| git restore --staged ruta | Saca el archivo del staging; en disco no cambia nada. | deshacer un git add |
| git restore --source=ref ruta | Trae un archivo como estaba en otro commit, sin mover la rama. | git restore --source=HEAD~1 gateway/app/main.py |
| git commit --amend | Reemplaza el último commit (mensaje o contenido) por uno nuevo, con otro SHA. | corregir un typo en el mensaje antes del push |
| git reset --soft | Mueve la rama atrás; staging y disco quedan igual: el cambio queda listo para recommitear. | juntar los últimos 3 commits en uno |
| git reset (--mixed) | Mueve la rama atrás y saca los cambios del staging; tus archivos no cambian. | Unstaged changes after reset: |
| git reset --hard | Mueve la rama y pisa staging y disco. Borra también los archivos nuevos que estaban en staging. | HEAD is now at b029a14 gateway: primera versión |
| ORIG_HEAD | Dónde estaba HEAD antes del último reset, merge o rebase. | git reset --hard ORIG_HEAD deshace un merge recién hecho |
| git revert | Commit nuevo con el cambio inverso. La historia queda intacta: es seguro después de push. | Revert "gateway: bajar INFER_TIMEOUT_S a 120 ms" |
| git revert -m 1 | Revierte un merge commit tomando como línea principal su primer padre (el main de antes del merge). | This reverts commit cd07ebf…, reversing changes made to 1380e71… |
| git stash | Guarda cambios sin commit y limpia el disco. Los archivos untracked (nuevos y sin git add), solo con -u. | Saved working directory and index state On main: probar 100 ms |
| git reflog | El diario local de HEAD: cada movimiento con su SHA. Ahí siguen los commits "perdidos". | HEAD@{1}: commit: gateway: bajar INFER_TIMEOUT_S a 120 ms |
| git push --force-with-lease | Sube una rama reescrita (amend, reset, rebase) solo si nadie la cambió desde tu último fetch. Solo para tu rama de PR. | (forced update) |
| git clean -n · -f | Lista (-n) o borra (-f) los archivos untracked. -f no tiene deshacer: mira primero con -n. | Would remove notas.txt |
| Comando | HEAD y la rama | Staging | Working tree | ¿Después de push? |
|---|---|---|---|---|
git restore ruta | =no se mueve | =igual | ✕la ruta vuelve a la versión del staging: lo que cambiaste sin git add se pierde | ✓sí: no toca commits |
git restore --staged ruta | =no se mueve | ↻la ruta vuelve a la versión de HEAD | =igual | ✓sí: no toca commits |
git commit --amend | ↻la rama pasa a un commit nuevo, con otro SHA | ↻lo que había entra al commit nuevo | =igual | !no: reescribe un commit publicado |
git reset --soft HEAD~1 | ↻vuelve 1 commit | =igual: queda con los cambios del commit deshecho | =igual | !no: reescribe |
git reset HEAD~1 (--mixed) | ↻vuelve 1 commit | ↻como el nuevo HEAD: los cambios salen del staging | =igual: los cambios quedan en disco | !no: reescribe |
git reset --hard HEAD~1 | ↻vuelve 1 commit | ✕como el nuevo HEAD: lo que estaba en staging se pierde | ✕como el nuevo HEAD: lo que no era commit se pierde | !no: reescribe |
git revert sha | ↻avanza: commit nuevo con el cambio inverso | ↻como el nuevo HEAD (exige trabajo limpio antes) | ↻como el nuevo HEAD | ✓sí: es el camino en main |
git stash push -u | =no se mueve | ↻se guarda en el stash y queda limpio | ↻se guarda en el stash y queda limpio | ✓sí: no toca commits |
git branch rescate HEAD@{n} | ↻HEAD no se mueve; una rama nueva apunta al commit perdido | =igual | =igual | ✓sí: solo agrega una etiqueta |
Los mismos comandos sobre el grafo
1reset: la rama vuelve atrás
git reset {--soft | --mixed | --hard} HEAD~1
Los tres modos mueven la rama igual; cambia qué pasa con staging y con tus archivos
| Modo | HEAD y la rama | Staging | Working tree |
|---|---|---|---|
--soft | ↻vuelve 1 commit | =con los cambios de C2 | =con los cambios de C2 |
--mixed | ↻vuelve 1 commit | ↻igual a C1 | =con los cambios de C2 |
--hard | ↻vuelve 1 commit | ✕igual a C1 | ✕igual a C1: los cambios de C2 solo siguen en el commit C2 |
El grafo es el mismo en los tres modos; cambia qué pasa con staging y disco. C2 no se borra: queda fuera de toda rama, en el reflog.
2revert: un commit nuevo que deshace
git revert C2
Nada se reescribe: para el remoto es avanzar, y acepta el push como el de cualquier commit. Es el rollback que deja rastro.
3amend: reemplaza el último commit
git commit --amend
C2' tiene el mismo padre y otro SHA. Si C2 ya estaba en el remoto, ahora tu rama y la remota divergen.
4reflog: rescatar un commit
git branch rescate HEAD@{1}
Después de un reset --hard, C2 solo estaba en el reflog. Con una etiqueta encima vuelve a estar a salvo; luego git merge --ff-only rescate lo trae a main.
Arriba, lo más nuevo; cada línea une un commit con su padre.commitcommit nuevosolo en el reflograma localrama remota (origin/…)HEADestás aquídónde estaba antes
Antes de deshacer, pregunta: ¿ya hice push? (sí → revert) y ¿esto fue commit alguna vez? (no → el reflog no lo tiene). Lo que llegó a estar en staging deja un blob suelto (una copia de su contenido) que git fsck --lost-found encuentra, sin nombre de archivo, hasta que git gc lo borre; lo que solo estuvo en disco no deja nada.
$ git switch -c revert/gateway-timeout
Switched to a new branch 'revert/gateway-timeout'
$ git log --oneline --graph -3
* cd07ebf Merge pull request #42 from equipo/feat/gateway-timeout
|\
| * c5aa999 gateway: bajar INFER_TIMEOUT_S a 120 ms
|/
* 1380e71 c01: gateway y manifests
$ git revert cd07ebf
error: commit cd07ebfa545a267cc125a8b5e05d7c53ec2910b9 is a merge but no -m option was given.
fatal: revert failed
$ git revert -m 1 --no-edit cd07ebf
[revert/gateway-timeout 3a36915] Revert "Merge pull request #42 from equipo/feat/gateway-timeout"
Date: Wed Oct 21 23:12:58 2026 -0600
1 file changed, 1 insertion(+), 1 deletion(-)
$ git show -U0
…
This reverts commit cd07ebfa545a267cc125a8b5e05d7c53ec2910b9, reversing
changes made to 1380e71e91f3dbc3460c8ca2a0f6581533705ff6.
…
-INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.12")) # 120 ms para Triton dentro del p99 ≤ 200 ms
+INFER_TIMEOUT_S = float(os.getenv("INFER_TIMEOUT_S", "0.15")) # 150 ms para Triton dentro del p99 ≤ 200 ms
$ git push -u origin revert/gateway-timeout-m falla. -m 1 elige el primer padre, el main de antes del merge (1380e71).INFER_TIMEOUT_S vuelve a 0.15. La historia queda intacta, así que no hace falta forzar nada.git revert 3a36915).Cómo recordarla
Antes de deshacer, pregunta: ¿ya hice push? Si no: restore, amend, reset. Si sí: revert. Y si algo "se perdió": git reflog. Con la config por defecto, un commit no se pierde antes de 30 días; lo que nunca commiteaste, sí.
Trampa
git reset --hard y git restore no tienen red: lo que no estaba en un commit (ni en el stash) se pierde, y --hard borra también los archivos nuevos que estaban en staging. Y un revert no borra un secreto de la historia: el commit viejo sigue ahí. Rota el secreto primero: crea uno nuevo e invalida el viejo (M15).
En el curso: M11 · Rollback: criterios automáticos y qué guardar para volver Docker y kubectl · Rollouts y escalado M15 · Secretos y credenciales M10 · CI/CD para modelos: la pirámide de tests
Qué vas a provocar: Probar amend, los tres modos de reset, el rescate de un commit "perdido" con el reflog, un revert y el stash, mirando qué queda en staging y en disco después de cada uno.
Necesitas: git 2.28 o más nuevo (git --version) y tu nombre y email configurados (git config --global user.name y user.email). Todo es local: no necesitas cuenta de GitHub.
mkdir ~/lab-git && cd ~/lab-git && git init -b main
mkdir -p gateway/app
printf 'INFER_TIMEOUT_S = 0.15\n' > gateway/app/main.py
git add . && git commit -m "gateway: primera versión"
printf 'INFER_TIMEOUT_S = 0.12\n' > gateway/app/main.py
git commit -am "gateway: bajar el timout"Qué deberías ver: Initialized empty Git repository in /Users/<tú>/lab-git/.git/, el commit (root-commit) y [main <sha>] gateway: bajar el timout, con un typo en el mensaje.
printf 'MAX_TOP_K = 20\n' > gateway/app/limits.py
git add gateway/app/limits.py
git commit --amend -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"
git log --onelineQué deberías ver: [main <sha nuevo>] gateway: bajar INFER_TIMEOUT_S a 120 ms, con una línea Date: (amend conserva la fecha del autor original) y 2 files changed. El log sigue teniendo dos commits: el viejo fue reemplazado, con otro SHA.
git reset --soft HEAD~1
git status -s
git commit -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"Qué deberías ver: A gateway/app/limits.py y M gateway/app/main.py, con la letra a la izquierda: --soft dejó todo en staging. El commit lo rehace igual.
git reset HEAD~1
git status -s
git add . && git commit -m "gateway: bajar INFER_TIMEOUT_S a 120 ms"Qué deberías ver: Unstaged changes after reset: y, debajo, M y gateway/app/main.py. Después, status muestra main.py con la M a la derecha y ?? gateway/app/limits.py: --mixed sacó todo del staging, pero tus archivos siguen como estaban.
git reset --hard HEAD~1
git log --oneline
ls gateway/appQué deberías ver: HEAD is now at <sha> gateway: primera versión. El log tiene un solo commit y ls muestra solo main.py: limits.py desapareció de disco. El commit no está perdido…
git reflog -n 3Qué deberías ver: Tres líneas: HEAD@{0}: reset: moving to HEAD~1, HEAD@{1}: commit: gateway: bajar INFER_TIMEOUT_S a 120 ms (el commit "perdido", con su SHA) y HEAD@{2}: reset: moving to HEAD~1.
git branch rescate 'HEAD@{1}'
git merge --ff-only rescate
git branch -d rescate
git log --oneline
ls gateway/appQué deberías ver: Updating <sha>..<sha>, Fast-forward (main solo avanza hasta el commit rescatado) y create mode 100644 gateway/app/limits.py; Deleted branch rescate (was <sha>). Otra vez dos commits, y limits.py volvió. Las comillas en 'HEAD@{1}' evitan que el shell toque las llaves.
git revert --no-edit HEAD
git log --oneline
cat gateway/app/main.pyQué deberías ver: [main <sha>] Revert "gateway: bajar INFER_TIMEOUT_S a 120 ms" y delete mode 100644 gateway/app/limits.py. Tres commits: el revert se suma arriba, y main.py vuelve a INFER_TIMEOUT_S = 0.15. Nada se reescribió.
printf 'INFER_TIMEOUT_S = 0.10\n' > gateway/app/main.py
printf 'probar con 100 ms\n' > notas.txt
git stash push -u -m "probar 100 ms"
git status -s
git stash listQué deberías ver: Saved working directory and index state On main: probar 100 ms. status -s no imprime nada (con -u también se guardó notas.txt) y la lista tiene stash@{0}: On main: probar 100 ms.
git stash pop
git status -sQué deberías ver: El status largo con main.py modificado y notas.txt sin seguir, y al final Dropped refs/stash@{0} (<sha>). Después, status -s: main.py con la M a la derecha y ?? notas.txt.
cd ~ && rm -rf ~/lab-gitQué deberías ver: No imprime nada.
Cómo arreglarlo y confirmarlo
Si ya hiciste push de lo que quieres deshacer: git revert en una rama y un PR, nunca reset con force push sobre main. Si un commit desapareció: git reflog, una rama encima y después lo integras con un merge.
Probado de verdad en una carpeta de prueba (git 2.50.1). Los SHAs y las rutas cambian en cada máquina.
¿Cómo llega tu cambio a main en un repo de equipo, y qué lo frena si algo está mal?
Sintaxis con variables
git remote add origin git@github.com:org/repo.git
git push -u origin ramaUn remoto (remote) es otra copia del repo; aquí, la de GitHub. origin es solo su nombre por defecto. -u deja tu rama siguiendo a origin/rama: después alcanza con git push y git pull a secas.
ssh-keygen -t ed25519 -C "email" ssh -T git@github.com
Crea una llave SSH: un par de archivos. El privado se queda en tu Mac; el .pub (público) lo agregas en GitHub → Settings → SSH and GPG keys. Si funciona, GitHub contesta Hi usuario! You've successfully authenticated, but GitHub does not provide shell access.
gh pr create --base main --title "título" --body "descripción" gh pr checks --watch gh pr merge --squash --delete-branch
gh es la CLI oficial de GitHub (su programa de terminal). Un pull request (PR) pide unir tu rama a main. checks --watch espera a que terminen los checks (los jobs de CI, las pruebas automáticas de cada cambio); merge (hacer la unión) falla si faltan aprobaciones o hay un check obligatorio en rojo.
git tag -a versión -m "mensaje" git push origin versión
Un tag anotado es una etiqueta fija sobre un commit, con autor, fecha y mensaje. git push no sube tags si no los nombras: usa git push origin versión o --follow-tags (solo anotados).
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| org | dueño del repo en GitHub: una persona o una organización | acme |
| repo | nombre del repo | classifier |
| rama | tu rama de trabajo | feat/slot-wait-env |
| comentario de la llave, para reconocerla en la lista | ana@example.com | |
| usuario | tu usuario de GitHub (lo escribe GitHub en la respuesta) | ana-ml |
| título | qué cambia, en una línea | gateway: SLOT_WAIT_S configurable por variable de entorno |
| descripción | el cambio, el motivo y cómo lo probaste | Permite ajustar la espera sin rebuild. 2 tests nuevos. |
| versión | nombre del tag; casi siempre mayor.menor.parche | v1.4.0 |
| mensaje | mensaje del tag anotado | gateway con SLOT_WAIT_S configurable |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| origin | Nombre por defecto del remoto: el repo de GitHub al que haces push y pull. | git remote -v |
| origin/main | Rama de seguimiento: tu copia de cómo estaba main en GitHub la última vez que hablaste con él (fetch, pull o push). No se mueve sola. | git status -sb → ## main...origin/main |
| URL por SSH o por HTTPS | git@github.com:… entra con tu llave SSH; https://github.com/… entra con un token personal (PAT) o con el que guarda gh. | git remote set-url origin git@github.com:acme/classifier.git |
| contraseña de tu cuenta | No sirve para que Git entre por HTTPS (push, o clone y pull de un repo privado): GitHub dejó de aceptarla el 13 de agosto de 2021. | remote: Support for password authentication was removed on August 13, 2021. |
| token personal (PAT) | Clave larga que creas en GitHub y que va donde Git pide contraseña por HTTPS. Mejor el fine-grained: solo ciertos repos y ciertos permisos. | github_pat_… (fine-grained) · ghp_… (classic) |
| gh auth login | Inicia sesión por el navegador y guarda el token en el llavero de macOS. Si eliges HTTPS y aceptas, también configura Git. | gh auth status |
| pull request (PR) | Pedido para unir tu rama (head) a otra (base), con el diff, los checks y la review en una página. | base: main ← head: feat/slot-wait-env |
| draft PR | Borrador: no se puede hacer merge y no pide review a los dueños del código hasta que lo marcas listo. | gh pr create --draft |
| review | Comment (opinas), Approve (apruebas) o Request changes (pides cambios). Tu propio PR no lo puedes aprobar. | 1 aprobación para mergear a main |
| CODEOWNERS | Archivo que dice quién es dueño de cada ruta; GitHub le pide review automáticamente. Va en .github/, en la raíz o en docs/. | reference/c01/model/ @acme/ml-platform |
| checks obligatorios | Jobs de CI que deben terminar en verde antes del merge (required status checks). Se eligen por el nombre del job. | tests |
| rama al día (strict) | Exige que tu rama tenga lo último de main antes del merge, para que los checks prueben la combinación real. | botón Update branch del PR |
| regla de protección o ruleset | Las dos protegen ramas. A una rama le aplica una sola regla de protección; rulesets, varios a la vez, y gana lo más estricto. | hasta 75 rulesets por repo |
| release | Página de GitHub colgada de un tag, con notas y archivos. gh la crea y, si el tag no existe, lo crea sobre lo último de la rama por defecto (main). | gh release create v1.4.0 --generate-notes |
Pull requestfeat/slot-wait-envmainde tu rama (head) a la rama base
Rama y commits tú
git switch -c feat/slot-wait-envTodo pasa en tu Mac; main no se toca.
Push tú
git push -u origin feat/slot-wait-envLa rama existe en GitHub.
PR abierto tú
gh pr create --base mainGitHub arma el diff y pide review a los CODEOWNERS de las rutas tocadas.
Checks GitHub Actions
gh pr checks --watchEl job tests corre sobre el merge de prueba con main.
Review otra persona del equipo
Approve o Request changes. Un push nuevo puede descartar la aprobación, si la regla lo pide.
Antes del merge: las reglas de main
Require a pull request before merging
1 aprobación
Frena: push directo a main y merge sin review
Require status checks before merging
tests
Frena: merge con el check en rojo o sin reportar
Require branches to be up to date before merging
activado
Frena: merge de una rama atrasada respecto de main
Block force pushes
activado (default en ramas protegidas)
Frena: reescribir la historia de main
Merge tú
gh pr merge --squash --delete-branchSolo se habilita con las reglas cumplidas.
Push a main GitHub Actions
Dispara tests → imagen → deploy, que espera la aprobación del environment.
túGitHub Actionsotra persona, en GitHubreglas que hacen esperar al merge
| Método | Qué queda en main | Bueno para | Ojo |
|---|---|---|---|
| Create a merge commit | Tus commits más un commit de merge que une las dos historias | Ver cuándo entró cada rama | Historia con bifurcaciones; git log --first-parent muestra solo la línea de main |
| Squash and merge | Un solo commit nuevo con todo el PR | Un commit por cambio: los primeros 7 caracteres de su SHA son el tag de la imagen del gateway | Se pierden los commits intermedios de la rama |
| Rebase and merge | Tus commits copiados uno por uno sobre main, sin commit de merge | Historia en línea recta y con detalle | GitHub crea commits nuevos, con otros SHA, aunque no haya conflictos |
Con la regla Require linear history, main no acepta commits de merge. El tag de la imagen (3f9c2ab) son los primeros 7 caracteres del SHA del commit que quedó en main, no del de tu rama.
git switch -c feat/slot-wait-env # rama nueva desde main
# … editas reference/c01/gateway/app/main.py y agregas 2 tests …
git add reference/c01/gateway
git commit -m "gateway: SLOT_WAIT_S configurable por variable de entorno"
git push -u origin feat/slot-wait-env # crea la rama en GitHub
gh pr create --base main --title "gateway: SLOT_WAIT_S configurable" \
--body "Permite ajustar la espera sin rebuild. 2 tests nuevos en test_api.py."
gh pr checks --watch # espera el check tests (pytest)
# Con 1 aprobación y tests en verde:
gh pr merge --squash --delete-branch
git switch main && git pull # tu main local, con el commit squash
git log --oneline -1 # sus primeros 7 caracteres serán el tag de la imagengit push -u crea la rama en GitHub y la deja como upstream (la rama remota que sigue): los próximos git push no necesitan argumentos.refs/pull/N/merge). Por eso un PR puede fallar en CI aunque tu rama sola pase: combinada con el main de ese momento, es otro código.gh pr merge respeta las reglas de la rama: sin aprobación o con tests en rojo, falla. Con --auto queda programado para cuando se cumplan, si el repo tiene activado auto-merge (Allow auto-merge).3f9c2ab) son el tag de la imagen que construye el workflow del gateway. En tu Mac salen con git rev-parse --short=7 HEAD; sin =7, git da más caracteres cuando el repo crece.--delete-branch borra la rama local y la remota después del merge: lo que importa ya está en main.Cómo recordarla
Rama → push → PR → checks y review → merge. Quien dice que no es la regla de la rama: sin aprobación o con tests en rojo, el merge no se habilita.
Trampa
Hacer push directo a main "porque es un cambio chico": si main no está protegida, GitHub lo acepta y el CD (el despliegue automático) construye y despliega algo que nadie revisó. Protege main desde el día 1 (PR obligatorio y check tests); en un repo privado del plan Free no hay reglas de rama: hace falta Pro, Team o Enterprise.
En el curso: M10 · CI/CD para modelos: la pirámide de tests M11 · Rollback: criterios automáticos y qué guardar para volver Herramientas · Infraestructura como código, CI y seguridad C01 · Archivos de referencia
Qué vas a provocar: Practicar con un remoto local lo que harás con GitHub: push con upstream, el error de una rama sin upstream y qué tags viajan con git push.
Necesitas: Git en tu Mac (viene con las Command Line Tools de Xcode). No necesitas cuenta de GitHub.
git init --bare -b main ~/lab-git-remote.git
mkdir ~/lab-git && cd ~/lab-git && git init -b main
git remote add origin ~/lab-git-remote.git
git remote -vQué deberías ver: Dos líneas: origin con la ruta de lab-git-remote.git, una (fetch) y otra (push). Ese repo bare (sin carpeta de trabajo) hace de GitHub. Si quedaron carpetas de otro lab, bórralas primero con el último paso.
echo "# gateway" > README.md && git add README.md && git commit -m "primer commit"
git push -u origin main
git status -sbQué deberías ver: * [new branch] main -> main y branch 'main' set up to track 'origin/main'. status muestra ## main...origin/main: tu main sigue a la del remoto.
git switch -c feat/slot-wait-env
echo "SLOT_WAIT_S=0.05" > config.env && git add config.env && git commit -m "gateway: SLOT_WAIT_S por env"
git pushQué deberías ver: fatal: The current branch feat/slot-wait-env has no upstream branch., con la sugerencia git push --set-upstream origin feat/slot-wait-env. Si tienes push.autoSetupRemote activado, el push funciona directo.
git push -u origin feat/slot-wait-env
git branch -vvQué deberías ver: [new branch] y, en branch -vv, cada rama con su upstream entre corchetes: [origin/feat/slot-wait-env] y [origin/main].
git switch main
git tag -a v1.0.0 -m "primera release del gateway"
git tag borrador
git push
git ls-remote --tags originQué deberías ver: Everything up-to-date, y ls-remote no imprime nada: git push no sube tags por su cuenta.
git push --follow-tags
git ls-remote --tags originQué deberías ver: [new tag] v1.0.0 -> v1.0.0: solo viaja el anotado. ls-remote lo lista dos veces: refs/tags/v1.0.0 (el objeto tag) y refs/tags/v1.0.0^{} (el commit al que apunta). borrador, que es liviano, se quedó en tu Mac.
git push origin borrador
git cat-file -t v1.0.0
git cat-file -t borradorQué deberías ver: [new tag] borrador -> borrador, y después tag y commit: el anotado es un objeto propio; el liviano es solo un nombre para el commit.
cd ~ && rm -rf ~/lab-git ~/lab-git-remote.gitQué deberías ver: Nada: borra el repo y el remoto del lab.
Cómo arreglarlo y confirmarlo
Si git push dice no upstream branch, usa git push -u origin RAMA una vez. Si un tag no aparece en GitHub, súbelo con git push origin TAG, o con git push --follow-tags si es anotado.
Probado de verdad en una carpeta de prueba (git 2.50.1).
¿Qué partes tiene un workflow, en qué orden corre cada cosa y cómo se pasan datos entre sí?
Sintaxis con variables
name: nombre
on:
evento:
branches: [rama]
permissions:
contents: read
jobs:
job:
runs-on: runner
timeout-minutes: minutos
steps:
- uses: dueño/acción@{versión}
with:
input: valor
- run: comandoUn workflow es un archivo YAML en .github/workflows/. on dice cuándo corre. Cada job corre en su propio runner (la máquina que lo ejecuta; los de GitHub son una máquina nueva y limpia en cada job) y sus steps (pasos) corren en orden dentro de esa máquina.
job_b:
needs: [job_a]
if: github.event_name == 'evento'Sin needs, los jobs corren en paralelo. Si job_a falla o se salta, job_b también se salta, salvo que su if use always().
outputs:
salida: ${{ steps.paso.outputs.salida }}
steps:
- id: paso
run: echo "salida=valor" >> "$GITHUB_OUTPUT"
# en un job con needs: [job] se lee ${{ needs.job.outputs.salida }}Un paso deja un dato escribiendo en el archivo $GITHUB_OUTPUT (reemplaza al viejo ::set-output). Otro job lo lee solo si nombra al primero en needs.
on:
workflow_dispatch:
inputs:
input:
type: tipo
required: trueAgrega el botón Run workflow, que solo aparece si el archivo está en la rama por defecto. Hasta 25 inputs; se leen con inputs.input.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| nombre | nombre que ves en la pestaña Actions | gateway |
| evento | qué lo dispara | push, pull_request, workflow_dispatch, schedule |
| rama | filtro de ramas; en pull_request, la rama base del PR | main |
| job | id del job: empieza con una letra o _ y sigue con letras, números, - o _ | tests |
| runner | etiqueta de la máquina | ubuntu-24.04 · [self-hosted, linux, x64, gpu-t4] |
| minutos | tope del job; si falta, 360 | 10 |
| dueño | usuario u organización dueña de la acción | actions |
| acción | repo de la acción | checkout |
| versión | versión mayor o SHA de 40 caracteres | v7 |
| input | parámetro de la acción o del workflow | python-version |
| valor | su valor; los números de versión, entre comillas | "3.13" |
| comando | comando de shell | pytest |
| job_a | job del que depende | tests |
| job_b | job que espera | imagen |
| salida | nombre del dato que pasa | digest |
| paso | id del paso que lo escribe | build |
| tipo | tipo del input | string, boolean, choice, number, environment |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| on | Los eventos que lo disparan: un push, un PR, el botón (workflow_dispatch) o un horario (schedule). | on: [push, pull_request] |
| branches / paths | Filtros: solo ciertas ramas, o solo si el commit toca ciertos archivos. | paths: ['reference/c01/gateway/**'] |
| jobs | Cada clave bajo jobs es un job: un grupo de pasos que corre en su propio runner. No cuentes con los archivos de otro job: pásalos con outputs o artifacts. | tests · imagen · deploy |
| runs-on | En qué runner corre: una etiqueta de GitHub o las etiquetas de tu propia máquina. | ubuntu-24.04 |
| steps | Los pasos del job, en orden; comparten carpeta y archivos. | checkout → pip install → pytest |
| uses + with | uses corre una acción (código ya hecho, de otro repo) en una versión fija; with le pasa sus inputs. | actions/setup-python@v7 con python-version: "3.13" |
| run | Comandos de terminal (shell). En Linux, si no pones la clave shell, corre con bash -e: para en el primer error, pero no ve un fallo en medio de un pipe (|). Con shell: bash suma pipefail, que sí lo ve. | run: pytest |
| env | Variables de entorno del workflow, del job o del paso; si un nombre se repite, gana la más cercana al paso. | env: { IMAGEN: … } |
| needs | Este job espera a otros. Si uno falla o se salta, este también se salta, salvo que su if use always(). | needs: tests |
| if | Corre el job o el paso solo si se cumple. Sin if vale success(): que nada antes haya fallado. | if: failure() · if: always() |
| ${{ }} | Expresión: GitHub calcula su valor y lo pega en el YAML antes de correr el paso. | ${{ github.sha }} |
| contextos | Objetos con datos del run (esta ejecución del workflow): github, env, vars, secrets, inputs, steps, needs, matrix, runner. | github.ref_name → main |
| $GITHUB_OUTPUT · $GITHUB_ENV | Archivos donde un paso deja una salida o una variable para los pasos que siguen (no para sí mismo). | echo "tag=3f9c2ab" >> "$GITHUB_OUTPUT" |
| timeout-minutes | Corta el job a los N minutos. Si falta: 360 (6 h). | timeout-minutes: 10 |
Archivo .github/workflows/gateway.yml
on: qué evento corre qué jobs
pull_request
testscorre imagenskipped deployskipped
imagen y deploy quedan en gris (skipped)
push a main
testscorre imagencorre deployespera aprobación
deploy espera la aprobación del environment produccion
testsruns-on: ubuntu-24.04
imagenruns-on: ubuntu-24.04
needs testsif github.event_name == 'push'outputs digest
deployruns-on: ubuntu-24.04
needs imagenenvironment produccion
job: una máquina nueva, pasos en ordenskipped (su if no se cumple)espera la aprobación del environment
name: gateway
on:
pull_request:
push: { branches: [main] }
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-24.04
timeout-minutes: 10
defaults:
run: { working-directory: reference/c01/gateway }
steps:
# … checkout, setup-python, pip install, pytest …
imagen:
needs: tests
if: github.event_name == 'push'
runs-on: ubuntu-24.04
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- id: tag
run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# … credenciales de AWS, login a ECR y build con id: build …
deploy:
needs: imagen
environment: produccion
# … usa ${{ needs.imagen.outputs.digest }} …tests: imagen tiene if: github.event_name == 'push', así que se salta, y deploy, que necesita a imagen, también.needs los ordena. Sin needs correrían a la vez, cada uno en su runner.defaults.run.working-directory vale para los run del job. Los with de las acciones siguen siendo rutas desde la raíz del repo.build lo devuelve, el job lo publica en outputs y deploy lo lee con needs.imagen.outputs.digest.permissions deja el GITHUB_TOKEN (la llave que GitHub le da a cada job) con solo contents: read en todos los jobs; los que hablan con AWS piden más en su propio bloque (sección 8).Cómo recordarla
on (cuándo) → jobs (qué, cada uno en su propio runner) → steps (en orden, en la misma máquina). Entre pasos, los datos viajan por archivos y $GITHUB_OUTPUT; entre jobs, solo por outputs o por artifacts (archivos que guarda el run).
Trampa
Pegar ${{ github.event.pull_request.title }}, o cualquier texto que escribe otra persona, dentro de run: GitHub lo copia tal cual al script antes de correrlo, y un título como a"; curl … | sh; " se ejecuta en tu runner. Pásalo por env y úsalo como "$TITULO". Y un if: ${{ x }} == 'y' siempre es verdadero: es un texto, no una comparación.
En el curso: M10 · CI/CD para modelos: la pirámide de tests Herramientas · Infraestructura como código, CI y seguridad M15 · Supply chain: imágenes, dependencias y pesos verificados C01 · Archivos de referencia
Qué vas a provocar: Ver en un repo de prueba cómo on, needs, if y outputs se vuelven un run: qué jobs corren, cuáles se saltan y qué pasa cuando uno falla.
Necesitas: una cuenta de GitHub, un repo vacío lab-actions y git configurado para hacer push (SSH, o gh auth login con la URL https://github.com/TU_USUARIO/lab-actions.git). Deja el repo: lo usan los labs de las secciones 7, 8 y 9. Gasta unos 2 minutos de Actions.
mkdir -p ~/lab-actions/.github/workflows && cd ~/lab-actions
git init -b main
git remote add origin git@github.com:TU_USUARIO/lab-actions.git
cat > .github/workflows/anatomia.yml <<'EOF'
name: anatomia
on:
push:
branches: [main]
workflow_dispatch:
inputs:
saludo:
type: string
default: hola
permissions:
contents: read
jobs:
primero:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
corto: ${{ steps.sha.outputs.corto }}
steps:
- id: sha
run: echo "corto=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- run: echo "evento=$GITHUB_EVENT_NAME rama=$GITHUB_REF_NAME"
segundo:
needs: primero
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- env:
CORTO: ${{ needs.primero.outputs.corto }}
SALUDO: ${{ inputs.saludo }}
run: echo "commit $CORTO, saludo '${SALUDO:-sin input}'"
solo-manual:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- run: echo "este job solo corre con el boton Run workflow"
EOF
git add . && git commit -m "workflow de prueba" && git push -u origin mainQué deberías ver: Antes, crea en github.com un repo vacío llamado lab-actions (New repository, sin README) y cambia TU_USUARIO por tu usuario. En la pestaña Actions aparece el run anatomia: primero, después segundo (imprime el commit corto y saludo 'sin input') y solo-manual en gris, porque su if es falso en un push.
github.com → lab-actions → Actions → anatomia → Run workflow → saludo: buenas → Run workflowQué deberías ver: Un run nuevo con evento workflow_dispatch: corren los tres jobs, solo-manual incluido, y segundo imprime saludo 'buenas'.
perl -pi -e 's/^ - id: sha$/ - run: exit 1\n - id: sha/' .github/workflows/anatomia.yml
git commit -am "primero falla a propósito" && git pushQué deberías ver: primero en rojo y segundo en gris (skipped): un job que necesita a otro que falló no corre. El run termina en rojo.
perl -pi -e 's/^ needs: primero$/ needs: primero\n if: always()/' .github/workflows/anatomia.yml
git commit -am "segundo corre siempre" && git pushQué deberías ver: Ahora segundo corre aunque primero falló, pero imprime commit vacío: el paso que escribía la salida nunca llegó a correr.
Cómo arreglarlo y confirmarlo
Si el run no aparece, revisa que el archivo esté en .github/workflows/ y en la rama main. Si aparece en rojo sin jobs, el YAML no es válido: la página del run dice en qué línea.
Verificado contra la documentación; no ejecutado aquí.
¿Cómo corres pytest en cada PR, rápido y sin que un test rojo llegue a main?
Sintaxis con variables
- uses: actions/setup-python@v7
with:
python-version: "versión"
cache: pip
cache-dependency-path: carpeta/requirements*.txt
- run: pip install -r carpeta/requirements-dev.txt
- run: pytest --junit-xml=reporte.xmlEn CI (integración continua: las pruebas automáticas que corren con cada push y cada PR), la caché de pip guarda los paquetes que pip bajó, no el entorno instalado: el pip install corre igual, pero saca los paquetes de la caché en vez de bajarlos. La versión va entre comillas: en YAML, 3.10 sin comillas es el número 3.1.
- uses: actions/cache@v6
with:
path: carpeta_cache
key: so-herramienta-${{ hashFiles('archivo_lock') }}
restore-keys: |
so-herramienta-Una caché (archivos que se guardan entre runs para no repetir trabajo) se busca por su key. Si coincide exacta es un hit; si no, restore-keys trae la más reciente que empiece igual. Una caché no se reescribe: si cambia el archivo, cambia la key y se guarda otra.
strategy:
fail-fast: false
matrix:
python-version: ["v1", "v2"]
steps:
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}Una matrix repite el job por cada combinación de valores, y cada copia es un check aparte (su propio resultado en el PR), como tests (3.13). Con fail-fast: true, el default, el primer rojo cancela a los demás.
- if: failure()
uses: actions/upload-artifact@v7
with:
name: nombre
path: ruta
retention-days: díasUn artifact es un archivo del run que bajas desde su página (en un zip). Sin retention-days, dura lo que diga el repo: 90 días por defecto. if: failure() lo sube solo cuando algo falló.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| versión | versión de Python, entre comillas | "3.13" |
| carpeta | carpeta del proyecto dentro del repo | reference/c01/gateway |
| reporte | nombre del reporte JUnit XML (un formato estándar de resultados de tests) | reporte-tests |
| carpeta_cache | carpeta que quieres guardar entre runs | ~/.cache/torch/hub/checkpoints |
| so | sistema operativo del runner (runner.os) | Linux |
| herramienta | prefijo que nombra la caché | pesos-torchvision |
| archivo_lock | archivo cuyo contenido cambia cuando cambia lo que guardas | model/requirements.txt |
| v1 | primera versión a probar | 3.13 |
| v2 | segunda versión | 3.14 |
| nombre | nombre del artifact, único en el run | reporte-tests |
| ruta | archivo o carpeta, desde la raíz del repo | reference/c01/gateway/reporte-tests.xml |
| días | días que se guarda: de 1 al máximo del repo (90 por defecto; un repo privado puede subirlo hasta 400) | 14 |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| actions/setup-python | Instala la versión de Python pedida (o la toma de las que ya trae el runner) y la pone primera en el PATH: los pasos siguientes usan ese python. | python-version: "3.13" |
| cache: pip | Guarda la carpeta de descargas de pip; la key incluye el hash de tus requirements. | cache-dependency-path: reference/c01/gateway/requirements*.txt |
| actions/cache | Caché a medida: tú eliges la carpeta y la key. | ~/.cache/torch/hub/checkpoints |
| hit / miss | Hit: la key coincide y se restaura al empezar. Miss: el job hace todo y, si termina bien, guarda la caché al final. | salida cache-hit: true |
| límite y limpieza | 10 GB por repo por defecto. Se borra lo que no se usa en 7 días y, si te pasas del límite, lo que hace más tiempo que nadie usa. | un admin puede subir el límite, con pago |
| alcance | Un run usa cachés de su rama y de la rama por defecto (un PR, también las de su rama base); la que crea un PR solo la ve ese PR. | un push a main calienta la caché de todos los PRs |
| cache-mode | Cuánto puede usar el job la caché: read (solo restaurar), write (restaurar y guardar), write-only (solo guardar) o none. En eventos que puede disparar cualquiera, como pull_request_target, el default es read. | cache-mode: read |
| pytest | Sale con 0 si todo pasó, 1 si algún test falló y 5 si no encontró tests. --junit-xml deja el resultado en un XML. | pytest --junit-xml=reporte-tests.xml |
| strategy.matrix | Repite el job por cada combinación de valores. Máximo 256 jobs por run. | python-version: ["3.13", "3.14"] |
| fail-fast | true (default): el primer job rojo de la matrix cancela a los otros. false: ves todos los resultados. | fail-fast: false |
| actions/upload-artifact | Sube archivos del run en un zip. Ignora archivos ocultos y no acepta dos artifacts con el mismo nombre en un run, salvo con overwrite: true (reemplaza al anterior). | name: reporte-tests |
| retención | Artifacts y logs duran 90 días por defecto; el repo puede elegir de 1 a 90 si es público y de 1 a 400 si es privado. Desde el 1 de octubre de 2026 el mismo plazo vale para checks y runs. | retention-days: 14 |
| check obligatorio | Un job que debe quedar en verde para el merge. Para elegirlo en la regla, tiene que haber terminado en verde en este repo en los últimos 7 días. | tests |
| continue-on-error | Deja seguir al job aunque el paso falle. En un paso de tests, esconde los rojos. | no lo uses con pytest |
| Para qué | Quién lo lee | Cuánto vive | Límite | En el gateway | |
|---|---|---|---|---|---|
| Caché (actions/cache, cache: pip) | Ir más rápido: no volver a bajar ni a calcular | Los runs siguientes de la misma rama o de la rama por defecto | Hasta 7 días sin uso (configurable) | 10 GB por repo por defecto | Descargas de pip; capas de Docker (type=gha) |
| Artifact (upload-artifact) | Guardar un resultado para mirarlo o pasarlo a otro job | Personas, desde la página del run, u otro job | 90 días por defecto | Cuota de almacenamiento de la cuenta; 500 por job | reporte-tests.xml cuando algo falla |
| Output de job (outputs) | Pasar un dato chico al job siguiente | Jobs que lo nombran en needs | Lo que dura el run | 1 MB por job, 50 MB por run | El digest de la imagen |
Si borrar algo rompe el pipeline, no era caché. Y una salida que contiene un secret no viaja al otro job: GitHub la descarta.
tests:
runs-on: ubuntu-24.04
timeout-minutes: 10
defaults:
run: { working-directory: reference/c01/gateway }
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: reference/c01/gateway/requirements*.txt
- run: pip install -r requirements-dev.txt
- run: pytest --junit-xml=reporte-tests.xml
- if: failure()
uses: actions/upload-artifact@v7
with: { name: reporte-tests, path: reference/c01/gateway/reporte-tests.xml }python:3.13-slim del Dockerfile: los tests prueban las mismas versiones que corren en producción, porque requirements-dev.txt incluye a requirements.txt.requirements*.txt toma los dos archivos: si cambia cualquiera, cambia la key y pip vuelve a descargar lo que necesite.pytest lee pytest.ini del gateway (testpaths = tests, -q). Los 24 tests usan un Triton falso: no hace falta GPU.working-directory solo afecta a los run.tests, y ese es el nombre que eliges como check obligatorio en la regla de main.Cómo recordarla
Caché para ir rápido (si se pierde, solo tarda más), artifact para mirar después y check obligatorio para que nada rojo llegue a main.
Trampa
Filtrar con paths un workflow cuyo check es obligatorio: un PR que no toca esas rutas no lo corre, el check queda en Waiting for status to be reported y el PR no se puede mergear. Pon la condición en el if de un job: un job saltado por if cuenta como verde.
En el curso: M10 · CI/CD para modelos: la pirámide de tests C01 · Archivos de referencia M10 · Reproducibilidad: los cinco ingredientes
Qué vas a provocar: Ver una caché pasar de miss a hit, una matrix de dos versiones de Python y un test rojo que deja su reporte como artifact.
Necesitas: el repo lab-actions de la sección 6 y push con tu acceso normal de git. Gasta unos 5 minutos de Actions.
cd ~/lab-actions && mkdir -p tests
printf 'def suma(a, b):\n return a + b\n' > calc.py
printf 'from calc import suma\n\n\ndef test_suma():\n assert suma(2, 2) == 4\n' > tests/test_calc.py
printf '[pytest]\npythonpath = .\ntestpaths = tests\n' > pytest.ini
printf 'pytest==9.1.1\n' > requirements.txt
cat > .github/workflows/tests.yml <<'EOF'
name: tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.13", "3.14"]
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest --junit-xml=reporte-${{ matrix.python-version }}.xml
- if: failure()
uses: actions/upload-artifact@v7
with:
name: reporte-${{ matrix.python-version }}
path: reporte-${{ matrix.python-version }}.xml
retention-days: 3
EOF
git add . && git commit -m "tests con matrix" && git pushQué deberías ver: Dos checks: tests (3.13) y tests (3.14). En el log de setup-python, la primera vez no hay caché; el paso Post de setup-python la guarda al final.
git commit --allow-empty -m "otra vuelta" && git pushQué deberías ver: Ahora setup-python restaura la caché (cache-hit: true) y pip instala sin descargar.
git switch -c test-roto
sed -i '' 's/== 4/== 5/' tests/test_calc.py
git commit -am "test roto a propósito" && git push -u origin test-rotoQué deberías ver: En github.com, Compare & pull request → Create pull request. Los dos checks quedan en rojo (fail-fast: false deja terminar a ambos) y, al pie de la página del run, aparecen reporte-3.13 y reporte-3.14 para descargar.
github.com → Settings → Rulesets → New ruleset → New branch ruleset
Enforcement status: Active · Target branches: Add a target → la rama por defecto
Require status checks before merging → agrega tests (3.13) y tests (3.14) con + → CreateQué deberías ver: Vuelve al PR: el merge queda bloqueado mientras los checks estén en rojo. Solo en un repo público, o privado con Pro, Team o Enterprise.
git switch main && git branch -D test-roto && git push origin --delete test-rotoQué deberías ver: Deleted branch test-roto y [deleted] test-roto: GitHub cierra el PR solo, porque su rama ya no existe. Borra el ruleset si no lo quieres.
Cómo arreglarlo y confirmarlo
Si setup-python falla con cache: pip, revisa que exista requirements.txt (o pyproject.toml) o fija cache-dependency-path. Si el check no aparece al armar la regla, haz que corra una vez: GitHub solo ofrece checks que terminaron en verde en los últimos 7 días.
Verificado contra la documentación; no ejecutado aquí.
¿Con qué permisos corre tu workflow, dónde guardas lo secreto y cómo entra a AWS sin guardar ninguna llave?
Sintaxis con variables
permissions:
contents: read
jobs:
job:
permissions:
contents: read
permiso: writeEl GITHUB_TOKEN es la llave temporal que GitHub le da a cada job para hablar con tu repo. permissions fija qué puede hacer: lo que no nombras queda en none, y un bloque en el job reemplaza al de arriba, así que ahí repites contents: read.
- name: paso
env:
VARIABLE: ${{ secrets.SECRETO }}
OTRA: ${{ vars.VARIABLE_CONFIG }}
run: comandoUn secret es un valor cifrado que guardas en Settings; en el log aparece como ***. Una variable (vars) es configuración no secreta y se ve tal cual. Las dos entran por env, nunca pegadas en el comando.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@{sha_acción} # v6.3.0
with:
role-to-assume: arn:aws:iam::cuenta:role/rol
aws-region: regiónOIDC (OpenID Connect): el job pide un token firmado por GitHub que dice quién es (repo, rama o environment, un entorno con reglas como produccion), y AWS lo cambia por credenciales de un rol (una identidad con permisos que el job toma prestada) que vencen solas, en 1 hora por defecto. id-token: write solo permite pedir ese token.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:org/repo:ref:refs/heads/rama"
}
}La trust policy del rol dice qué tokens pueden asumirlo. El sub es el campo del token que dice quién lo pide; IAM (el servicio de permisos de AWS) rechaza la política si no lo revisa o si acepta cualquiera (*). Con environment, el sub pasa a ser repo:org/repo:environment:entorno. Hay dos formatos: un repo creado, renombrado o transferido después del 15 de julio de 2026 (o que eligió el formato nuevo) agrega los IDs numéricos del dueño y del repo, como repo:acme@123/classifier@456:ref:refs/heads/main. Copia el que usa tu repo: con el otro, AWS responde Not authorized.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| job | id del job | imagen |
| permiso | el permiso extra que necesita ese job | id-token (AWS), packages (GHCR), pull-requests (comentar el PR) |
| paso | nombre del paso | Bajar pesos del hub |
| VARIABLE | variable de entorno que lee tu código | HF_TOKEN |
| SECRETO | nombre del secret: letras, números y _; no empieza con GITHUB_ ni con un número | HF_TOKEN |
| OTRA | otra variable de entorno | MLFLOW_TRACKING_URI |
| VARIABLE_CONFIG | nombre de la variable de configuración | MLFLOW_TRACKING_URI |
| comando | lo que lee esas variables | python descargar_pesos.py |
| sha_acción | commit de 40 caracteres de la acción; la versión va en el comentario | e1253824e5c10ff9df46874f81ed3ec929e19cfd |
| cuenta | id de la cuenta de AWS, 12 dígitos | 123456789012 |
| rol | rol de IAM que asume el job | gha-classifier-gateway |
| región | región de AWS | us-east-1 |
| org | dueño del repo en GitHub | acme |
| repo | nombre del repo | classifier |
| rama | rama que puede asumir el rol | main |
| entorno | environment del job | produccion |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| GITHUB_TOKEN | Token que GitHub crea al empezar cada job y que solo sirve para este repo. Vence al terminar el job: como mucho, 6 h en runners de GitHub y 24 h en runners propios. | secrets.GITHUB_TOKEN · github.token |
| permisos por defecto | Los fija el repo o la org. En repos personales y orgs creados desde febrero de 2023: solo lectura de contents y packages. En los nuevos, el workflow tampoco puede crear ni aprobar PRs. No dependas de eso: decláralo. | Settings → Actions → Workflow permissions |
| permissions: | Ajusta el GITHUB_TOKEN del workflow o del job. Lo no nombrado queda en none; write-all abre todo. | contents: read |
| id-token: write | Deja pedir un token OIDC. No da permiso de escribir en el repo. | jobs imagen y deploy del gateway |
| secrets | Valores cifrados de org, de repo o de environment; si un nombre se repite, gana el más específico. 100 por repo, 48 KB cada uno. | secrets.HF_TOKEN |
| vars | Configuración no secreta, visible en el log: URLs, nombres, regiones. | vars.MLFLOW_TRACKING_URI |
| *** en el log | GitHub tapa el valor exacto de un secret. Un valor derivado (en base64, recortado, un token armado con él) no se tapa solo. | ::add-mask::VALOR tapa uno nuevo |
| PRs desde forks | Con el evento pull_request, un PR desde un fork (la copia del repo en la cuenta de otra persona) no recibe secrets, y su GITHUB_TOKEN es de solo lectura. | el job tests no necesita ninguno |
| environment | Un entorno (como produccion) con reglas: quién aprueba, desde qué ramas se despliega y sus propios secrets. | environment: produccion |
| environment inexistente | Si el nombre no existe, el run lo crea vacío, sin reglas ni aprobación. | produccion ≠ producción |
| token OIDC (JWT) | Un texto firmado por GitHub para ese job (JWT es el formato estándar de ese texto), con datos (claims) como sub, aud, ref y environment. | iss: https://token.actions.githubusercontent.com |
| sub | Quién lo pide: repo y rama, PR o environment. La trust policy lo compara. Los repos creados o renombrados después del 15 de julio de 2026 (o que lo activan) lo llevan con IDs. | repo:acme/classifier:ref:refs/heads/main · con IDs: repo:acme@123/classifier@456:ref:refs/heads/main |
| aud | Para quién es el token. Para AWS es sts.amazonaws.com, el default de configure-aws-credentials. | token.actions.githubusercontent.com:aud |
| credenciales temporales | STS (el servicio de AWS que entrega credenciales) devuelve llave, secreto y token de sesión que vencen solos: 1 h por defecto. | role-duration-seconds: 3600 |
Job imagen (GitHub Actions)
Pide un token OIDC
Tiene permissions: id-token: write; configure-aws-credentials lo pide con aud = sts.amazonaws.com.
GitHub (token.actions.githubusercontent.com)
Firma un JWT con los datos del job
sub = repo:acme/classifier:ref:refs/heads/main
Token OIDC (un JWT) firmado por GitHub
AWS STS
Cambia el token por el rol gha-classifier-gateway (AssumeRoleWithWebIdentity)
Verifica la firma con el proveedor OIDC registrado en IAM y compara aud y sub con la trust policy.
La trust policy del rol compara
aud del token: sts.amazonaws.comsts.amazonaws.comsub del token: repo:acme/classifier:ref:refs/heads/mainrepo:acme/classifier:ref:refs/heads/mainCoinciden: STS entrega credenciales.
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/classifier:ref:refs/heads/main"
}
}
}AWS STS
Devuelve credenciales temporales
Llave, secreto y token de sesión: 1 h por defecto.
Job imagen
Login a ECR y push
amazon-ecr-login canjea esas credenciales por un token de ECR que dura 12 h.
Qué sub manda cada job, contra esta trust policy
| Cuándo | sub del token | ¿Entra? |
|---|---|---|
| push a main, job sin environment | repo:acme/classifier:ref:refs/heads/main | ✓ entra |
| pull_request, job sin environment | repo:acme/classifier:pull_request | ✗ Not authorized |
| job con environment: produccion | repo:acme/classifier:environment:produccion | ✗ Not authorized |
| push a main en un repo creado o renombrado después del 15 de julio de 2026 (formato con IDs) | repo:acme@123/classifier@456:ref:refs/heads/main | ✗ Not authorized |
el job (GitHub Actions)GitHubAWScredenciales: vencen solas
| Rol de IAM | Lo usa | sub en la trust policy | Puede |
|---|---|---|---|
| gha-classifier-gateway | job imagen (push a main) | repo:acme/classifier:ref:refs/heads/main | push a ECR, solo al repo classifier-gateway |
| gha-classifier-deploy | job deploy (environment produccion) | repo:acme/classifier:environment:produccion | eks:DescribeCluster (para armar el kubeconfig) y desplegar en el namespace classifier, con una access entry de EKS (el permiso del rol dentro del cluster) |
| gha-classifier-modelo | workflow triton-modelo (botón, desde main) | repo:acme/classifier:ref:refs/heads/main | leer el checkpoint y la muestra de eval en S3; push a classifier-triton |
Los nombres son ilustrativos, y los sub tienen el formato de un repo de antes del 15 de julio de 2026; en uno más nuevo llevan IDs (repo:acme@123/classifier@456:…). Ojo: gateway y modelo aceptan el mismo sub, el de cualquier job en main; para separarlos, dale a triton-modelo su propio environment. Un solo rol para todo también funciona, pero entonces cualquier job que consiga un token puede desplegar.
deploy:
needs: imagen
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: produccion
concurrency: { group: deploy-gateway-produccion, cancel-in-progress: false }
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-classifier-deploy
aws-region: us-east-1
- env:
IMAGEN: 123456789012.dkr.ecr.us-east-1.amazonaws.com/classifier-gateway@${{ needs.imagen.outputs.digest }}
run: |
aws eks update-kubeconfig --name classifier --region us-east-1
kubectl -n classifier set image deployment/gateway gateway="$IMAGEN"
kubectl -n classifier rollout status deployment/gateway --timeout=5menvironment: produccion, el sub del token es repo:acme/classifier:environment:produccion (en un repo con IDs, repo:acme@123/classifier@456:environment:produccion). La trust policy de gha-classifier-deploy pide ese valor: el job imagen no puede asumir este rol, y otro job (o un PR) solo podría si usa ese environment. Por eso produccion lleva reviewers y solo deja desplegar desde main.permissions repite contents: read: al declarar el bloque en el job, lo que no nombras queda en none.Cómo recordarla
Sin llaves guardadas: id-token: write → token firmado por GitHub → la trust policy compara sub → credenciales que vencen solas. Lo secreto va en secrets, lo demás en vars, y nada de eso en el YAML.
Trampa
Agregar environment: produccion a un job que ya entraba a AWS: su sub cambia de …:ref:refs/heads/main a …:environment:produccion y STS lo rechaza con Not authorized to perform sts:AssumeRoleWithWebIdentity. Renombrar o transferir el repo hace lo mismo: desde el 15 de julio de 2026, eso lo pasa al sub con IDs. Y escribir mal el nombre del environment no da error: crea uno nuevo, sin aprobación.
En el curso: M15 · Secretos y credenciales M15 · Supply chain: imágenes, dependencias y pesos verificados M15 · Autenticación y autorización entre servicios Herramientas · Infraestructura como código, CI y seguridad
Qué vas a provocar: Ver por dentro el token OIDC de un job (sus claims, nunca el token) y cómo cambia el sub cuando el job usa un environment.
Necesitas: el repo lab-actions de la sección 6, público (con el plan Free, un repo privado no tiene environments), y push con tu acceso normal de git. No necesitas cuenta de AWS: el token se pide pero no se usa.
cd ~/lab-actions
cat > .github/workflows/oidc.yml <<'EOF'
name: oidc
on: workflow_dispatch
permissions:
contents: read
jobs:
sin-environment:
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
id-token: write
steps:
- run: |
test -n "$ACTIONS_ID_TOKEN_REQUEST_URL" || { echo "sin id-token: write no hay token OIDC"; exit 1; }
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value | cut -d. -f2 > payload.txt
python3 -c 'import base64,json; p=open("payload.txt").read().strip(); c=json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))); print({k: c.get(k) for k in ("sub", "aud", "ref", "environment", "repository_owner_id", "repository_id")})'
con-environment:
runs-on: ubuntu-24.04
timeout-minutes: 5
environment: lab
permissions:
id-token: write
steps:
- run: |
test -n "$ACTIONS_ID_TOKEN_REQUEST_URL" || { echo "sin id-token: write no hay token OIDC"; exit 1; }
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value | cut -d. -f2 > payload.txt
python3 -c 'import base64,json; p=open("payload.txt").read().strip(); c=json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))); print({k: c.get(k) for k in ("sub", "aud", "ref", "environment", "repository_owner_id", "repository_id")})'
EOF
git add . && git commit -m "lab oidc" && git pushQué deberías ver: El workflow oidc queda en el repo. Solo imprime claims: el token completo nunca sale al log.
github.com → lab-actions → Actions → oidc → Run workflowQué deberías ver: sin-environment imprime aud 'sts.amazonaws.com' y un sub con IDs, como 'repo:TU_USUARIO@12345678/lab-actions@987654321:ref:refs/heads/main': tu repo es de después del 15 de julio de 2026, y esos números son el repository_owner_id y el repository_id que se imprimen al lado. con-environment imprime un sub que termina en ':environment:lab' y environment 'lab'. En Settings → Environments aparece lab: el run lo creó, sin reglas.
perl -0pi -e 's/ permissions:\n id-token: write\n//' .github/workflows/oidc.yml
git commit -am "sin id-token" && git push
# y otra vez: Actions → oidc → Run workflowQué deberías ver: sin-environment falla con sin id-token: write no hay token OIDC: sin ese permiso, el job no recibe con qué pedir el token. con-environment sigue funcionando.
git rm -q .github/workflows/oidc.yml && git commit -m "fin del lab oidc" && git pushQué deberías ver: El workflow desaparece de Actions. Borra el environment lab en Settings → Environments.
Cómo arreglarlo y confirmarlo
Si el sub no es el que esperabas, mira tres cosas: si el job tiene environment, si corrió por un PR y si el repo lleva IDs (creado o renombrado después del 15 de julio de 2026). Ese mismo texto es el que va en la trust policy de AWS.
Verificado contra la documentación; no ejecutado aquí.
¿Cómo construye CI (el sistema que prueba y arma cada cambio solo) una imagen una sola vez, para la CPU de los nodos, con un tag que nunca se reusa?
Sintaxis con variables
- uses: docker/setup-buildx-action@{sha_acción} # v4.4.1
- id: build
uses: docker/build-push-action@{sha_acción} # v7.4.0
with:
context: carpeta
platforms: linux/amd64
push: true
tags: registry/repo:tag
cache-from: type=gha
cache-to: type=gha,mode=maxbuildx es el build de Docker con BuildKit (el motor que arma las capas). context es la carpeta con el Dockerfile: si es una ruta, necesitas actions/checkout antes. type=gha guarda las capas en la caché de Actions.
- id: tag
run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# después: ${{ steps.tag.outputs.tag }} → tagLos primeros 7 caracteres del commit, como 3f9c2ab en k8s/gateway.yaml. En un PR, GITHUB_SHA es un merge de prueba que nunca llega a main: por eso la imagen se publica solo en push a main.
- uses: aws-actions/configure-aws-credentials@{sha_acción} # v6.3.0
with:
role-to-assume: arn:aws:iam::cuenta:role/rol
aws-region: región
- id: ecr
uses: aws-actions/amazon-ecr-login@{sha_acción} # v2.1.7
# ${{ steps.ecr.outputs.registry }} → cuenta.dkr.ecr.región.amazonaws.comamazon-ecr-login hace el docker login a ECR (el registry de AWS: el servicio que guarda tus imágenes) con las credenciales del job. El token de ECR dura 12 h, y la action cierra la sesión (logout) al terminar el job.
docker buildx imagetools inspect registry/repo:tag
Desde tu Mac, con el login a ECR hecho y sin bajar la imagen: lista las plataformas del índice (la lista de variantes por CPU que guarda el registry). Debe decir linux/amd64; la entrada unknown/unknown es la attestation (el certificado de cómo se construyó) que agrega el build.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| sha_acción | commit de 40 caracteres de la acción; la versión va en el comentario | c3c9e263c25d99ce0380d002d59b67737d91b0dc |
| carpeta | contexto del build: la carpeta con el Dockerfile | reference/c01/gateway |
| registry | host del registry | 123456789012.dkr.ecr.us-east-1.amazonaws.com |
| repo | repositorio de la imagen en el registry | classifier-gateway |
| tag | los primeros 7 caracteres del commit: nunca se reusa | 3f9c2ab |
| cuenta | id de la cuenta de AWS | 123456789012 |
| rol | rol de IAM con permiso de push a ese repositorio | gha-classifier-gateway |
| región | región del registry | us-east-1 |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| docker/setup-buildx-action | Arranca BuildKit en un contenedor aparte (driver docker-container, el default): así sí puede guardar la caché en Actions. | driver: docker-container (default) |
| docker/build-push-action | Corre docker buildx build con lo que pongas en with; push: true sube al terminar. | salidas: digest, imageid, metadata |
| context | Sin context usa el contexto Git: BuildKit baja el commit solo e ignora cambios hechos en pasos anteriores. Con una ruta, usa tu checkout. | context: reference/c01/gateway |
| platforms | Para qué CPU es la imagen. El runner ubuntu-24.04 es amd64: construye nativo, sin emular. | linux/amd64 |
| tags | Los nombres con que sube la imagen, uno por línea o separados por comas. | …/classifier-gateway:3f9c2ab |
| cache-from / cache-to: type=gha | Guarda y reusa capas en la caché de Actions; mode=max incluye las etapas intermedias. Cuenta para los 10 GB del repo. | cache-to: type=gha,mode=max |
| provenance | El build agrega por defecto una attestation de procedencia (de dónde y cómo se construyó): mode=min en repos privados, max en públicos. | aparece como unknown/unknown en el índice |
| digest | El sha256 de lo que subió: identifica el contenido. Es lo que firmas y despliegas. | steps.build.outputs.digest |
| aws-actions/amazon-ecr-login | docker login a ECR con las credenciales de AWS del job. Su salida registry es el host de ECR. | steps.ecr.outputs.registry |
| permisos de IAM para el push | ecr:GetAuthorizationToken (sobre "*": no se limita por repo), más BatchCheckLayerAvailability, InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload, PutImage y BatchGetImage sobre ese repo. | rol gha-classifier-gateway |
| tags inmutables en ECR | Es una opción de cada repo de ECR (por defecto, los tags se pueden pisar); el del C01 la tiene activada. Con ella, subir otra imagen con un tag que ya existe falla: tag invalid: … cannot be overwritten because the repository is immutable (en la API, ImageTagAlreadyExistsException). | un re-run que reconstruye un commit ya publicado |
| ${GITHUB_SHA::7} | Recorte de bash: los primeros 7 caracteres de la variable, siempre 7. En tu Mac, lo mismo es git rev-parse --short=7 HEAD (sin =7, git da más cuando el repo crece). | 3f9c2ab |
| salida con un secret | Si una salida de job contiene un valor enmascarado, GitHub no la pasa al otro job: Skip output … since it may contain secret. | el digest viaja sin problema: no es secreto |
| docker/login-action | Login genérico a un registry. En GHCR (el registry de GitHub) alcanza el GITHUB_TOKEN con packages: write. | registry: ghcr.io |
gateway · gateway.yml (cada PR y cada push a main)
PR abierto
GitHub
El job tests corre sobre el merge de prueba con main.
tests: pytest, 24 tests≈ 1–2 min
runner ubuntu-24.04
Con la caché de pip, el pip install no descarga.
review y mergelo que tarde la review
GitHub
1 aprobación y el check tests en verde.
tests otra vez y build linux/amd64≈ 2–5 min
runner ubuntu-24.04
Con type=gha solo se rehacen las capas que cambiaron.
push a ECR, tag = 7 caracteres del SHA≈ 1 min
Amazon ECR
Sube solo las capas nuevas.
aprobación del environment produccionlo que tarde la persona
GitHub
El job deploy espera sin ocupar un runner.
kubectl set image y rollout status≈ 1–3 min
EKS
Pods nuevos listos antes de bajar los viejos (maxUnavailable: 0). Con GitOps, este paso es un PR que cambia el digest en el manifest.
Triton · triton-modelo.yml (botón, solo con un modelo nuevo)
export a ONNX y paridad en CPUminutos
runner T4 propio
parity_check.py con ONNX Runtime.
engine TensorRT e imagen de Tritonminutos
runner T4 propio
build_engine.sh aborta si la GPU no es una T4.
paridad FP16 contra ese Tritonminutos
runner T4 propio
Un exit 1 corta todo antes del push.
push a ECR y registro como challengerminutos (imagen de varios GB)
Amazon ECR y MLflow
El canary y el paso a champion siguen en CD.
túGitHubautomático (runner, Actions, Argo CD)espera a una personaAWSKubernetes≈ minduración estimada
imagen:
needs: tests
if: github.event_name == 'push'
runs-on: ubuntu-24.04
timeout-minutes: 20
# permissions: contents: read e id-token: write (sección 8)
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- id: tag
run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-classifier-gateway
aws-region: us-east-1
- id: ecr
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: reference/c01/gateway
platforms: linux/amd64
push: true
tags: ${{ steps.ecr.outputs.registry }}/classifier-gateway:${{ steps.tag.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=maxtests: cada commit de main produce una imagen, construida una sola vez.${GITHUB_SHA::7} da el mismo tag de 7 caracteres que usa el curso (3f9c2ab), aunque el repo crezca. El repo de ECR del C01 está configurado con tags inmutables (no es el default de ECR): nadie puede volver a subir ese tag con otro contenido.platforms: linux/amd64: el runner ya es amd64, así que no emula. En tu Mac (arm64) el mismo build sería emulado y lento: que lo haga CI.type=gha reusa capas entre runs: si solo cambió app/, la capa del pip install sale de la caché, porque el Dockerfile copia requirements.txt antes que el código.digest como salida: deploy despliega ese contenido exacto, no un tag.Cómo recordarla
Una imagen por commit, construida una vez en CI: tests → build linux/amd64 → push con tag = 7 caracteres del SHA (${GITHUB_SHA::7}) → el deploy usa su digest. Nunca latest, nunca desde tu Mac.
Trampa
Construir en tu Mac y subir desde ahí "porque es más rápido": sale linux/arm64 (o amd64 emulado y lento) y con un tag que ningún run de CI puede explicar. Y un re-run que reconstruye un commit ya publicado, con otro digest, falla en el push con tag invalid: … cannot be overwritten because the repository is immutable: es la inmutabilidad haciendo su trabajo, no un bug.
En el curso: M5 · Registries y tags inmutables M5 · Un Dockerfile de ML, decisión por decisión M15 · Supply chain: imágenes, dependencias y pesos verificados M10 · CI/CD para modelos: la pirámide de tests
Qué vas a provocar: Que CI construya y publique una imagen linux/amd64 con tag = SHA en GitHub Container Registry (GHCR), sin AWS y sin secrets tuyos, y comprobar desde tu Mac para qué CPU es.
Necesitas: el repo lab-actions de la sección 6, público; Docker Desktop corriendo en tu Mac e internet. No necesitas AWS ni crear tokens.
cd ~/lab-actions
printf 'FROM busybox:1.38\nCMD ["echo", "hola desde CI"]\n' > Dockerfile
cat > .github/workflows/imagen.yml <<'EOF'
name: imagen
on:
push:
branches: [main]
permissions:
contents: read
jobs:
imagen:
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- run: echo "IMAGEN=ghcr.io/${GITHUB_REPOSITORY,,}:${GITHUB_SHA::7}" >> "$GITHUB_ENV"
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ env.IMAGEN }}
cache-from: type=gha
cache-to: type=gha,mode=max
EOF
git add . && git commit -m "imagen en GHCR" && git pushQué deberías ver: En Actions, el run imagen en verde. En la página del repo, Packages muestra lab-actions con un tag de 7 caracteres: el commit corto.
docker buildx imagetools inspect ghcr.io/TU_USUARIO/lab-actions:SHA_CORTOQué deberías ver: Platform: linux/amd64, más una entrada unknown/unknown (la attestation). Usa tu usuario en minúsculas. Funciona sin login porque un paquete publicado desde un workflow hereda la visibilidad del repo, y el tuyo es público. Si igual te lo niega, hazlo público en Packages → lab-actions → Package settings → Change visibility.
docker run --rm --platform linux/amd64 ghcr.io/TU_USUARIO/lab-actions:SHA_CORTOQué deberías ver: hola desde CI: corre emulada, porque tu Mac es linux/arm64 y pediste amd64. Sin --platform, docker busca en el índice una variante para tu Mac, no la encuentra y puede fallar con no matching manifest for linux/arm64/v8 in the manifest list entries.
git commit --allow-empty -m "otro commit" && git pushQué deberías ver: Un tag nuevo (otro commit corto) en Packages; en el log del build, los pasos que no cambiaron salen como CACHED.
docker rmi ghcr.io/TU_USUARIO/lab-actions:SHA_CORTO
# en github.com: Packages → lab-actions → Package settings → Delete this packageQué deberías ver: Untagged y Deleted en tu Mac; en GitHub, el paquete desaparece. Si terminaste los labs, borra también el repo lab-actions.
Cómo arreglarlo y confirmarlo
Si el push falla con denied, revisa permissions: packages: write en el job. Si el build falla con repository name must be lowercase, tu usuario tiene mayúsculas: el paso con ${GITHUB_REPOSITORY,,} las pasa a minúsculas.
Verificado contra la documentación; no ejecutado aquí.
La imagen ya está en ECR: ¿quién la lleva al cluster, quién aprueba, qué evita dos deploys a la vez y cómo vuelves atrás?
Sintaxis con variables
- id: app
uses: actions/create-github-app-token@v3
with: { client-id: "${{ vars.var_app }}", private-key: "${{ secrets.secreto_app }}" }
- env: { IMG: "repo:tag@${{ needs.job_imagen.outputs.digest }}" }
run: sed -i "s|repo[:@].*$|$IMG|" manifest
- uses: peter-evans/create-pull-request@{sha_action}
with: { token: "${{ steps.app.outputs.token }}", branch: deploy/servicio, title: "deploy: servicio tag" }CD (continuous delivery, entrega continua) es llevar a producción, por etapas, lo que CI (las pruebas automáticas de cada cambio) ya probó. Hay dos caminos; este es el primero. GitOps, lado de CI: GitOps quiere decir que el estado de producción vive en git: los manifests (los YAML de Kubernetes) dicen qué imagen corre. Después del push a ECR, el job cambia la línea image: del manifest: el tag dice de qué commit salió y el digest (la huella sha256 del contenido) fija los bytes exactos. Luego abre o actualiza un pull request con el token de una GitHub App (una identidad de máquina con permisos propios). Con ese token, los checks del PR corren solos. Desplegar es hacer merge de ese PR.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: app, namespace: argocd }
spec:
project: default
source: { repoURL: repo_git, targetRevision: main, path: carpeta }
destination: { server: https://kubernetes.default.svc, namespace: ns }
syncPolicy: { automated: { prune: true, selfHeal: true } }GitOps, lado del cluster. Argo CD corre dentro del cluster, compara lo que dice main con lo que corre y aplica la diferencia (un sync). selfHeal deshace los cambios hechos a mano en el cluster; prune borra lo que sacaste de git. Los dos vienen apagados si no los pones.
deploy:
needs: job_imagen
environment: entorno
concurrency: { group: deploy-servicio-entorno, cancel-in-progress: false }
steps: # antes: el rol de deploy por OIDC (sección 8), aws eks update-kubeconfig y un kubectl de la versión del cluster
- run: kubectl -n ns set image deployment/servicio contenedor=registry/repo@{digest} && kubectl -n ns rollout status deployment/servicio --timeout=plazoEstilo kubectl, el segundo camino y el de gateway.yml: el job empuja el cambio al cluster. Un environment (un entorno con nombre, con reglas y secretos propios) lo deja en Waiting hasta que apruebe alguien de la lista de required reviewers. concurrency deja un solo deploy a la vez en ese grupo. set image cambia solo la imagen, por digest; con kubectl apply -f, el manifest tiene que traer ya la imagen nueva. Si los pods nuevos no quedan listos, rollout status falla con exceeded its progress deadline (600 s sin avance, por defecto) y el job queda rojo; Kubernetes no vuelve atrás solo: los pods viejos siguen sirviendo.
git switch -c revert-deploy origin/main git revert -m 1 sha_merge # PR con merge commit; con squash: git revert sha git push -u origin revert-deploy # PR → merge → Argo CD aplica la versión anterior
Rollback = revert: un commit nuevo que deshace el del deploy. La historia queda completa: qué se desplegó, cuándo y cómo se volvió. Un merge commit tiene dos padres, así que -m 1 dice que el lado bueno es main. El botón Revert de un PR mergeado hace lo mismo: abre un PR nuevo.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| var_app | variable del repo con el client ID de la GitHub App | DEPLOY_APP_CLIENT_ID |
| secreto_app | secret con la llave privada de la App (nunca en git) | DEPLOY_APP_PRIVATE_KEY |
| repo | el repositorio de ECR, sin el registry | classifier-gateway |
| tag | los 7 primeros caracteres del SHA del commit (el job los saca con ${GITHUB_SHA::7}) | 9d41e7c |
| registry | el registry de ECR de la cuenta | 123456789012.dkr.ecr.us-east-1.amazonaws.com |
| digest | el digest que devolvió el push (output del job imagen) | sha256:… (64 caracteres hex) |
| contenedor | el contenedor del Deployment que cambia de imagen | gateway |
| job_imagen | el job que subió la imagen y publica su digest como output | imagen |
| manifest | el archivo con la línea image: | reference/c01/k8s/gateway.yaml |
| sha_action | el SHA completo de la versión de la action (fijada, sección 8) | 5f6978faf089d4d20b00c7766989d076bb2fc7f1 (v8.1.1) |
| servicio | el Deployment que cambia | gateway |
| app | nombre de la Application de Argo CD | classifier |
| repo_git | URL del repo que lee Argo CD (si es privado, con una credencial de solo lectura) | https://github.com/acme/classifier.git (ilustrativo) |
| carpeta | carpeta de los manifests dentro del repo | reference/c01/k8s |
| ns | namespace de destino | classifier |
| entorno | environment de GitHub | produccion |
| plazo | cuánto espera rollout status antes de fallar | 10m (gateway) · 25m (Triton) |
| sha_merge | el merge commit del PR del deploy | el que muestra git log --merges -1 |
| sha | el commit único de un PR mergeado con squash | el que muestra git log -1 |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| GitOps | Los manifests de producción viven en git y un agente dentro del cluster aplica lo que dice main. Desplegar es un merge; volver, un revert. | Argo CD o Flux (M10) |
| Argo CD | Revisa el repo cada ≤ 3 min (120 s más hasta 60 s al azar), o al instante con un webhook (un aviso que GitHub le manda en cada push). Con automated, aplica las diferencias solo. | Application → reference/c01/k8s |
| automated · prune · selfHeal | automated: sincroniza cuando cambia git. prune: borra lo que sacaste de git. selfHeal: deshace los cambios a mano en el cluster. prune y selfHeal vienen apagados. | un kubectl edit se revierte en segundos |
| peter-evans/create-pull-request | Commitea lo que cambió el job en una rama y abre el PR. Si el PR ya existe, lo actualiza; si no hay cambios, no hace nada. | branch: deploy/gateway |
| PR abierto con GITHUB_TOKEN | El repo tiene que permitirlo (Allow GitHub Actions to create and approve pull requests, apagado por defecto en repos personales y en organizaciones nuevas). Desde junio de 2026, los workflows de ese PR esperan a que alguien con permiso de escritura (write) apriete Approve workflows to run. | sin aprobación, el check obligatorio no corre |
| token de GitHub App · PAT | Con un token de GitHub App (o un personal access token, mejor de grano fino), el PR lo abre esa identidad y sus workflows corren solos. El token de la App dura 1 h y se revoca al terminar el job. | actions/create-github-app-token@v3 |
| environment | Entorno con nombre, reglas y secretos propios. El job que lo nombra espera a que se cumplan las reglas; sus secretos llegan recién después de aprobar. | environment: produccion |
| required reviewers | Hasta 6 personas o equipos; con que una apruebe, el job sigue. Prevent self-review impide aprobar tu propio deploy. Sin respuesta en 30 días, el job falla. | Review deployments → Approve and deploy |
| environments según el plan | Required reviewers y wait timer (una espera fija antes de desplegar): en Free, Pro y Team solo funcionan en repos públicos; en privados hace falta Enterprise. Deployment branches (qué ramas pueden desplegar): también en privados con Pro o Team. En Free, los environments solo existen en repos públicos. | tu repo de práctica: público |
| concurrency | Grupo donde corre un solo job o workflow a la vez; el que llega después espera como pending. Es independiente del environment. | group: deploy-gateway-produccion |
| queue: single · queue: max | single (el default): espera uno solo; si llega otro, el que esperaba se cancela. max: hasta 100 en fila, en orden. max no se combina con cancel-in-progress: true. | Canceling since a higher priority waiting request for '…' exists |
| cancel-in-progress: true | Cancela también el que está corriendo. Bien para los tests de un PR; en un deploy puede dejarlo a medias: el job se corta entre pasos o antes de confirmar el rollout. | group: pr-${{ github.head_ref || github.run_id }} |
| kubectl desde el job | El runner necesita red hasta el API server de EKS (la puerta de entrada del cluster) y un rol de AWS con access entry (el permiso de EKS que deja entrar a ese rol). set image cambia solo la imagen (lo que hace gateway.yml); git ya no dice qué corre. | kubectl -n classifier set image deployment/gateway gateway=…@sha256:… |
| versión de kubectl | kubectl solo garantiza hablar con clusters de su versión menor ± 1 (la menor es el segundo número de la versión: el 37 de 1.37.1). El de ubuntu-24.04 es 1.37 (sirve para 1.36–1.38); con un cluster 1.35, el mínimo del C01, baja en el job el kubectl de esa versión. | curl -LO https://dl.k8s.io/release/v1.35.9/bin/linux/amd64/kubectl |
| git revert | Commit nuevo que deshace otro. Un merge commit necesita -m 1; un commit de squash, no. Con GitOps, ese revert es el rollback. | Revert "Merge pull request #7 …" |
Estilo GitOps (el del curso)
PR con el cambio
tú · GitHub
Rama, push y pull request a main.
Tests≈ 1–2 min (est.)
CI · ubuntu-24.04
pytest: los 24 tests del gateway, sin GPU. Es un check obligatorio.
Review y mergeminutos a horas
el equipo · GitHub
Checks en verde y una aprobación; merge a main.
Build y push≈ 2–5 min (est.)
CI · ubuntu-24.04 → ECR
Rol por OIDC, buildx linux/amd64, push a ECR con tag = los 7 primeros caracteres del SHA, en un repo configurado con tags inmutables.
O, en vez de los pasos 5–8: Estilo kubectl con environment
Job deploy en Waitinghasta que alguien apruebe (falla a los 30 días)
GitHub
environment: produccion con required reviewers; concurrency deja un deploy a la vez.
kubectl set image y rollout statusgateway ≈ 1–3 min (est.)
CI
Por digest, como gateway.yml. El runner necesita red al API server de EKS, un rol con access entry y un kubectl de la versión del cluster (± 1 versión menor).
PR del manifest≈ 10–30 s (est.)
CI (GitHub App) · GitHub
Cambia la línea image: de reference/c01/k8s/gateway.yaml (tag y digest); sus checks corren solos.
Merge del manifestlo que tarde la persona
quien aprueba el deploy · GitHub
Aquí está la aprobación del estilo GitOps.
Sync de Argo CD≤ 3 min (polling) · segundos con webhook
Argo CD · dentro del cluster
Ve el commit nuevo en main y aplica la diferencia.
Rolloutgateway ≈ 1–3 min (est.) · Triton ≈ 8–22 min
Kubernetes · EKS
Pods nuevos antes de bajar los viejos (maxUnavailable: 0). Triton: 2 pods de a uno, cada uno con nodo GPU, imagen y engine (estimación del curso).
Rollback = revertgateway: minutos · Triton: ≈ 5–20 min si la release anterior ya no está viva (estimación del curso)
Con GitOps, un kubectl rollout undo dura hasta el próximo sync (o segundos, con selfHeal): el rollback de verdad es el revert.
túGitHubautomático (runner, Actions, Argo CD)espera a una personaAWSKubernetes≈ minduración estimada
# .github/workflows/gateway.yml (extracto). Ilustrativo, armado con los pasos del curso: el C01 no trae workflows.
# … el job imagen (sección 9) sube classifier-gateway a ECR y publica outputs.digest
# Este job va en lugar del job deploy de gateway.yml (kubectl): mismo digest, camino GitOps.
manifest-pr: # estilo GitOps: Argo CD aplica lo que llega a main
needs: imagen
if: github.event_name == 'push' # solo después del merge a main, nunca en un PR
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read # el GITHUB_TOKEN solo lee: la rama y el PR los hace la GitHub App
concurrency: { group: deploy-gateway-pr, cancel-in-progress: false }
steps:
- uses: actions/checkout@v7
- id: app # token de 1 h de la GitHub App de deploy, solo para este repo
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.DEPLOY_APP_CLIENT_ID }}
private-key: ${{ secrets.DEPLOY_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- id: tag
run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Apuntar el manifest a la imagen nueva (tag para leer, digest para fijar)
env:
IMG: "classifier-gateway:${{ steps.tag.outputs.tag }}@${{ needs.imagen.outputs.digest }}"
run: sed -i "s|classifier-gateway[:@].*$|${IMG}|" reference/c01/k8s/gateway.yaml
- uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ steps.app.outputs.token }} # con el token de la App, los checks del PR corren solos
branch: deploy/gateway # siempre la misma rama: si el PR ya existe, lo actualiza
title: "deploy: gateway ${{ steps.tag.outputs.tag }}"
commit-message: "deploy: gateway ${{ steps.tag.outputs.tag }}"
body: "La imagen ya está en ECR. Al hacer merge, Argo CD la despliega; para volver atrás, revierte este PR."…/classifier-gateway:3f9c2ab pasa a …/classifier-gateway:9d41e7c@sha256:…. El tag dice de qué commit salió; el digest fija los bytes (con los dos, la imagen se baja por el digest).k8s/ (las personas o equipos que CODEOWNERS nombra como responsables), lo aprueba quien opera el servicio. Después, Argo CD aplica main en ≤ 3 min, o en segundos con un webhook.GITHUB_TOKEN: con GITHUB_TOKEN, el repo tiene que activar Allow GitHub Actions to create and approve pull requests (apagado por defecto en repos personales y en organizaciones nuevas) y, desde junio de 2026, los workflows de ese PR quedan esperando a que alguien con permiso de escritura (write) apriete Approve workflows to run. Si nadie lo hace, el check obligatorio nunca corre y el merge queda bloqueado. Con el token de la App, el PR lo abre la App y sus checks corren solos.concurrency con la cola por defecto (queue: single): si se juntan varios merges, solo espera el más nuevo. Los que esperaban antes se cancelan y el PR queda con la imagen más nueva. Aquí es justo lo que quieres.main, el merge de este PR dispara otro build y otro PR. Filtra el push con paths (solo el código del gateway) o pon los manifests en otro repo; la App necesita acceso a ese repo (GITHUB_TOKEN solo sirve en el repo del workflow).actions/, que son de GitHub, van a su major.Cómo recordarla
CI construye, git decide, el cluster copia. Desplegar es un merge que cambia una línea; volver es un revert de ese merge. El environment pone a una persona en el medio y concurrency pone a los deploys en fila.
Trampa
arreglar producción con kubectl set image o kubectl rollout undo cuando hay GitOps. Dura poco: con selfHeal, Argo CD vuelve a lo que dice git en segundos, y sin él, en el próximo sync. El rollback de verdad es un git revert del commit del deploy. Y nunca cancel-in-progress: true en un job de deploy: el job se corta a mitad de camino y nadie confirma qué quedó corriendo.
En el curso: M10 · CI/CD para modelos: la pirámide de tests M11 · Canary: porcentajes, tiempos y criterios para avanzar M11 · Rollback: criterios automáticos y qué guardar para volver M5 · Registries y tags inmutables Docker y kubectl · Rollouts y escalado C01 · Modos de falla y degradación
Qué vas a provocar: Simular GitOps en tu máquina (un repo bare hace de repo de manifests y main es lo que Argo CD aplicaría): desplegar con un merge, volver con un revert (de un merge commit y de un squash) y, si quieres, ver en GitHub un environment con aprobación y concurrency.
Necesitas: Una terminal de macOS (sed -i '' es la forma de macOS) y git con user.name y user.email configurados. Para los pasos opcionales: tu cuenta de GitHub, un repo nuevo, vacío y público (en Free, los required reviewers solo existen en repos públicos) con un environment produccion que te tenga como required reviewer (Settings → Environments → New environment → Required reviewers → Save protection rules), push con tu autenticación de siempre y gh con sesión iniciada (o la web).
git init --bare -b main ~/lab-git-remote.git && mkdir ~/lab-git && cd ~/lab-git && git init -b main && git remote add origin ~/lab-git-remote.git && mkdir k8s && echo "image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/classifier-gateway:3f9c2ab" > k8s/gateway.yaml && git add k8s && git commit -m "gateway 3f9c2ab" && git push -u origin mainQué deberías ver: Initialized empty Git repository dos veces, [main (root-commit) …] gateway 3f9c2ab, * [new branch] main -> main y branch 'main' set up to track 'origin/main'. Un manifest mínimo: solo la línea que cambia en cada deploy.
git switch -c deploy/gateway && sed -i '' 's/:3f9c2ab/:9d41e7c/' k8s/gateway.yaml && git commit -am "deploy: gateway 9d41e7c" && git push -u origin deploy/gatewayQué deberías ver: Switched to a new branch 'deploy/gateway', el commit deploy: gateway 9d41e7c y * [new branch] deploy/gateway -> deploy/gateway. Es lo que hace el job de CI: cambia el tag y empuja una rama para el PR.
git switch main && git merge --no-ff -m "Merge pull request #7 from acme/deploy/gateway" deploy/gateway && git push && git show origin/main:k8s/gateway.yamlQué deberías ver: Merge made by the 'ort' strategy., el push de main y image: …/classifier-gateway:9d41e7c. Así queda un PR mergeado con Create a merge commit; esa línea es la que Argo CD aplicaría.
git revert HEADQué deberías ver: error: commit … is a merge but no -m option was given. y fatal: revert failed. Un merge tiene dos padres: hay que decir contra cuál deshacer.
git revert -m 1 --no-edit HEAD && git push && git show origin/main:k8s/gateway.yaml && git log --oneline --graph -4Qué deberías ver: [main …] Revert "Merge pull request #7 from acme/deploy/gateway", el push y otra vez …:3f9c2ab. El grafo muestra el commit inicial, el merge del deploy y el revert arriba: la historia guarda qué se desplegó y cómo se volvió. En un repo real, el revert entra con un PR.
git switch -c deploy/gateway-2 && sed -i '' 's/:3f9c2ab/:5b2e8aa/' k8s/gateway.yaml && git commit -qam "deploy: gateway 5b2e8aa" && git switch main && git merge --squash deploy/gateway-2 && git commit -m "deploy: gateway 5b2e8aa (#8)" && git revert --no-edit HEAD && cat k8s/gateway.yamlQué deberías ver: Squash commit -- not updating HEAD, el commit deploy: gateway 5b2e8aa (#8), Revert "deploy: gateway 5b2e8aa (#8)" sin pedir -m y otra vez …:3f9c2ab. Con Squash and merge, el PR es un commit normal.
mkdir -p .github/workflows && printf '%s\n' 'name: deploy-lab' 'on: workflow_dispatch' 'permissions:' ' contents: read' 'concurrency:' ' group: deploy-produccion' ' cancel-in-progress: false' 'jobs:' ' deploy:' ' runs-on: ubuntu-24.04' ' timeout-minutes: 5' ' environment: produccion' ' steps:' ' - run: echo "desplegando ${GITHUB_SHA::7} (run $GITHUB_RUN_NUMBER)" && sleep 60' > .github/workflows/deploy-lab.yml && git add .github && git commit -m "workflow de deploy de prueba" && git remote add github https://github.com/TU_CUENTA/lab-cd.git && git push github mainQué deberías ver: Opcional, en GitHub (cambia TU_CUENTA). El push crea main en tu repo de prueba con el workflow deploy-lab. El environment produccion ya tiene que existir con tus reglas: si no existe, GitHub lo crea solo al correr el job, sin aprobación.
for i in 1 2 3; do gh workflow run deploy-lab.yml --repo TU_CUENTA/lab-cd; sleep 5; doneQué deberías ver: En la pestaña Actions: el 1.er run queda Waiting, con Review deployments; el 2.º, pending, porque el 1.º tiene el grupo deploy-produccion; al llegar el 3.º, el 2.º se cancela con Canceling since a higher priority waiting request for 'deploy-produccion' exists. Sin gh: Run workflow tres veces en la web.
gh run list --workflow deploy-lab.yml --repo TU_CUENTA/lab-cd --limit 3Qué deberías ver: Antes, en la web: en el 1.er run, Review deployments → produccion → Approve and deploy (corre ≈ 1 min); cuando el 3.º quede Waiting, Reject. La lista muestra uno completado, uno cancelado (el 2.º) y uno fallido (el rechazado).
cd ~ && rm -rf ~/lab-git ~/lab-git-remote.gitQué deberías ver: Sin salida. Si hiciste los pasos de GitHub, borra el repo de prueba en Settings → General → Delete this repository.
Cómo arreglarlo y confirmarlo
Revert que pide -m: era un merge commit; usa git revert -m 1 SHA. El job no queda Waiting: el environment se creó sin required reviewers (o se creó solo al correr el job); configúralo antes. Ningún run se cancela: los lanzaste muy espaciados (con queue: max, tampoco se cancelaría ninguno).
Verificado contra la documentación. Los pasos 1–6 y 10 (git) se corrieron con git 2.50.1 en macOS y zsh, con un repo bare local como remoto. Los pasos 7–9 usan GitHub y se verificaron contra docs.github.com (environments, concurrency, gh workflow run), sin correrlos aquí.
¿En qué máquina corre cada job, cuánto puede durar, cuánto cuesta y cuándo conviene una máquina propia, como la T4 del C01?
Sintaxis con variables
jobs:
job:
runs-on: imagen
timeout-minutes: minUn runner es la máquina que ejecuta un job. Con una imagen de GitHub (GitHub-hosted), cada job recibe una VM (máquina virtual) nueva. timeout-minutes corta el job si se pasa: el default es 360 min (6 h), que también es el máximo en GitHub-hosted.
runs-on: [self-hosted, so, arq, label]
Un runner self-hosted es una máquina tuya con el programa del runner instalado. Las labels son etiquetas: el job va a un runner que tenga todas las que pide. self-hosted, el sistema y la arquitectura las pone el runner solo; las demás son tuyas.
./config.sh --url https://github.com/org/repo --token token_registro --labels label --ephemeral --unattended ./run.sh
Registrar el runner, en la máquina. El token de registro sale de Settings → Actions → Runners → New self-hosted runner (o de la API) y vence en 1 h. Con --ephemeral, el runner toma un job y se desregistra: la base para crear una máquina por job y apagarla al final.
runs-on: group: grupo labels: [label]
En una organización, los runners se juntan en runner groups (grupos con su lista de repos permitidos). Con group y labels, el runner tiene que cumplir las dos cosas. Un larger runner de GitHub (más grande, o con GPU) se elige por su grupo o por su nombre, que es su label.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| job | nombre del job | tests |
| imagen | imagen de GitHub; fíjala en vez de -latest | ubuntu-24.04 |
| min | minutos antes de que GitHub cancele el job | 10 (tests) · 120 (engine de Triton) |
| so | sistema operativo: label que pone el runner | linux |
| arq | arquitectura: label que pone el runner | x64 |
| label | label propia del runner | gpu-t4 |
| org/repo | dónde se registra el runner | acme/classifier (ilustrativo) |
| token_registro | token de registro: lo genera GitHub, vence en 1 h; nunca va a git | Settings → Actions → Runners |
| grupo | runner group de la organización | gpu |
| minutos de cada job; se redondean hacia arriba | 2 min 10 s → 3 | |
| minutos del plan para repos privados, según el plan; en un repo público, los runners estándar no cobran | 2,000 Free · 3,000 Pro y Team · 50,000 Enterprise Cloud | |
| precio por minuto del runner (los larger runners no usan los incluidos) | US$0.006 Linux 2-core · US$0.052 GPU T4 | |
| tiempo que la máquina propia está prendida (job más arranque) | ≈ 45 min por corrida (est.) | |
| precio on-demand de la máquina | g4dn.xlarge ≈ US$0.526/h (estimación del curso) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| ubuntu-24.04 · ubuntu-latest | Imágenes de GitHub, con Docker, kubectl, AWS CLI, gh y varias versiones de Python ya instalados (la lista está en actions/runner-images y cambia con cada imagen). ubuntu-latest hoy es 24.04 y se mueve con aviso, en una migración de 1–2 meses: fija la versión. | runs-on: ubuntu-24.04 |
| ubuntu-24.04-arm | Runner estándar arm64 (procesador ARM, como el de tu Mac). Gratis en repos públicos, como los x64; en privados también existe y gasta minutos. | uname -m → aarch64 |
| runner estándar | Repo privado: 2 vCPU, 8 GB de RAM y 14 GB de disco. Repo público: 4 vCPU y 16 GB. | nproc · free -h · df -h / |
| larger runner | Más CPU, RAM y disco, o GPU: 1 T4 de 16 GB con 4 vCPU, 28 GB y 176 GB. Solo en Team o Enterprise Cloud; se cobra siempre, aunque el repo sea público. | GPU Linux: US$0.052/min |
| self-hosted | Tu máquina con el programa del runner. Sale por HTTPS 443 a GitHub y pregunta si hay trabajo: no abre puertos de entrada. | la g4dn con T4 del C01 |
| labels por defecto | self-hosted, el sistema (linux, windows, macOS) y la arquitectura (x64, ARM, ARM64). No distinguen mayúsculas. | [self-hosted, linux, x64, gpu-t4] |
| runner group | Grupo de runners de una organización, con la lista de repos que pueden usarlo. | runs-on: { group: gpu } |
| --ephemeral · JIT | El runner corre un job y se desregistra. JIT (just-in-time) hace lo mismo con una configuración que pides a la API. | ./config.sh … --ephemeral |
| ARC | Actions Runner Controller: un programa que corre dentro de Kubernetes y crea runners como pods según los jobs en cola. | runners efímeros en EKS |
| timeout-minutes | Corta el job (o el paso) a los N minutos. Default 360. Tope por job: 6 h en GitHub-hosted, 5 días en self-hosted. | 10 en tests, 120 en el engine |
| en cola (queued) | Si ningún runner libre tiene esas labels: Waiting for a runner to pick up this job…; con self-hosted, a las 24 h en cola, el job falla. | runner apagado o labels mal escritas |
| minutos facturados | Cada job se redondea al minuto de arriba. Repos públicos con runners estándar: gratis. Privados: los incluidos del plan y después por minuto. Self-hosted: GitHub no cobra minutos. | Linux 2-core: US$0.006/min |
| jobs en paralelo | Según el plan: 20 en Free, 40 en Pro, 60 en Team, 500 en Enterprise (macOS: 5, o 50 en Enterprise). El resto espera en cola. | una matrix grande hace fila |
| self-hosted en un repo público | Casi nunca: cualquiera abre un PR y puede correr código en tu máquina, que no se limpia sola entre jobs. | solo en repos privados |
GitHub-hosted
runs-on: ubuntu-24.04
En el C01: tests e imagen del gateway (gateway.yml)
Self-hosted
runs-on: [self-hosted, linux, x64, gpu-t4]
En el C01: engine e imagen de Triton en la g4dn con T4 (triton-modelo.yml)
| GitHub-hosted | Self-hosted | |
|---|---|---|
| Quién pone y mantiene la máquina | GitHub | Tú: sistema, driver de NVIDIA, Docker, parches y el runner |
| Máquina limpia en cada job | Sí: una VM nueva por job | Solo si es efímera (--ephemeral o JIT) y la recreas |
| Hardware | x64 o arm64; privado: 2 vCPU, 8 GB, 14 GB de disco · GPU T4 solo como larger runner (Team o Enterprise Cloud) | Lo que elijas: una g4dn.xlarge con 1 T4 |
| Costo | Gratis en repos públicos (estándar); en privados, los minutos incluidos según el plan y después por minuto | GitHub no cobra minutos; pagas la máquina (≈ US$0.526/h on-demand) |
| Tope por job | 6 h | 5 días (y 24 h en cola) |
| Red | Internet, con IPs que cambian | La de tu VPC: llega a recursos privados |
| Riesgo con PRs de afuera | Bajo: la VM se descarta | Alto: un PR puede correr código en tu máquina; nunca en repos públicos |
la máquina es de GitHubla máquina es tuya
# Extractos de gateway.yml y triton-modelo.yml. Ilustrativos, armados con los pasos del curso: el C01 no trae workflows.
# gateway.yml: máquinas de GitHub, una VM nueva por job
jobs:
tests:
runs-on: ubuntu-24.04 # x64: linux/amd64 sin emulación; fija la versión, no ubuntu-latest
timeout-minutes: 10 # 24 tests tardan segundos: 10 min ya es una falla
# …
imagen:
runs-on: ubuntu-24.04
timeout-minutes: 20
# …
---
# triton-modelo.yml: la T4 propia, a mano o desde el pipeline del modelo
on:
workflow_dispatch: # nunca pull_request: un PR no corre código en la T4
jobs:
engine-e-imagen:
runs-on: [self-hosted, linux, x64, gpu-t4] # todas las labels: solo la g4dn las tiene
timeout-minutes: 120 # sin esto, un trtexec colgado retiene la T4 los 360 min del default
# … build_engine.sh, docker build triton/, paridad, push y registrotests e imagen: una VM nueva por job. En un repo privado tiene 2 vCPU, 8 GB de RAM y 14 GB de disco (4 vCPU y 16 GB si el repo es público). Es x64, así que linux/amd64 se construye sin emulación.--ephemeral): toma un job, se desregistra y la apagas. Para prenderla cuando hay un job en cola: ARC (en Kubernetes) o tu propia automatización con el webhook workflow_job.workflow_dispatch: la T4 nunca corre código de un PR. En un repo público, ni pensarlo: cualquiera puede abrir un PR y una máquina persistente guarda lo que el job anterior dejó.timeout-minutes en cada job. En self-hosted podrías subirlo hasta 5 días, pero el GITHUB_TOKEN se renueva solo hasta 24 h (en GitHub-hosted dura lo que el job, como mucho 6 h).Cómo recordarla
labels eligen, timeout corta, minutos cuestan. GitHub-hosted: una VM limpia por job, gratis en repos públicos. Self-hosted: tu hardware (la T4), tu mantenimiento y tu riesgo; efímero, por workflow_dispatch y nunca en un repo público.
Trampa
un runner self-hosted persistente que corre jobs de pull_request, o cualquiera en un repo público. El código de un PR corre en tu máquina, que no se limpia entre jobs: puede leer credenciales del disco o dejar algo instalado para el próximo job. Y ubuntu-latest se mueve: cuando pase a otra versión de Ubuntu, cambian Python, compiladores y librerías sin que toques el YAML.
En el curso: M10 · CI/CD para modelos: la pirámide de tests C01 · Costo y cómo reducirlo M8 · TensorRT M13 · Instancias GPU y costo por hora Herramientas · Infraestructura como código, CI y seguridad M15 · Supply chain: imágenes, dependencias y pesos verificados
Qué vas a provocar: Ver con tus ojos en qué máquina cae cada job (x64 y arm64), qué pasa cuando un job se pasa de su timeout y qué pasa cuando ningún runner tiene las labels que pide un job.
Necesitas: Tu cuenta de GitHub y un repo nuevo, vacío y público (lab-runners), creado en la web; push con tu autenticación de siempre; gh con sesión iniciada (o la web). En un repo público, los runners estándar no cobran minutos. No registres un runner self-hosted en un repo público.
git clone https://github.com/TU_CUENTA/lab-runners.git ~/lab-git && cd ~/lab-gitQué deberías ver: Cloning into '/Users/…/lab-git'... y warning: You appear to have cloned an empty repository. (Cambia TU_CUENTA; el repo lo creas vacío y público en la web.)
mkdir -p .github/workflows && printf '%s\n' 'name: lab-runners' 'on: workflow_dispatch' 'permissions:' ' contents: read' 'jobs:' ' maquina:' ' strategy:' ' matrix:' ' os: [ubuntu-24.04, ubuntu-24.04-arm]' ' runs-on: ${{ matrix.os }}' ' timeout-minutes: 5' ' steps:' ' - run: echo "$RUNNER_ENVIRONMENT $RUNNER_OS $RUNNER_ARCH" && uname -m && nproc && free -h && df -h /' ' se-pasa:' ' runs-on: ubuntu-24.04' ' timeout-minutes: 1' ' steps:' ' - run: sleep 120' ' sin-runner:' ' runs-on: [self-hosted, linux, x64, gpu-t4]' ' timeout-minutes: 5' ' steps:' ' - run: echo nunca corre' > .github/workflows/runners.yml && git add .github && git commit -m "lab runners" && git push -u origin mainQué deberías ver: El commit y * [new branch] main -> main. Tres jobs: maquina (en x64 y en arm64), se-pasa (timeout de 1 min) y sin-runner (pide labels que ningún runner tiene).
gh workflow run runners.yml && sleep 15 && RUN=$(gh run list --workflow runners.yml --limit 1 --json databaseId --jq '.[0].databaseId') && gh run view "$RUN"Qué deberías ver: El run con cuatro jobs: maquina (ubuntu-24.04), maquina (ubuntu-24.04-arm), se-pasa y sin-runner, este último en cola. En la web, sin-runner dice Waiting for a runner to pick up this job... Sin gh: Actions → lab-runners → Run workflow.
sleep 90 && gh run view "$RUN"Qué deberías ver: Los dos maquina en verde; se-pasa en rojo, con las anotaciones The job running on runner … has exceeded the maximum execution time … y The operation was canceled.; sin-runner sigue esperando: lo haría hasta 24 h.
gh run cancel "$RUN" && sleep 10 && gh run view "$RUN" --log | grep -E "hosted|x86_64|aarch64|Mem:"Qué deberías ver: Cancelas el run (si no, sigue en cola hasta 24 h). En el log: github-hosted Linux X64 y x86_64 en un job; github-hosted Linux ARM64 y aarch64 en el otro; Mem: ≈ 15–16Gi de total (repo público: 4 vCPU y 16 GB).
cd ~ && rm -rf ~/lab-gitQué deberías ver: Sin salida. Borra el repo de prueba en la web: Settings → General → Delete this repository.
Cómo arreglarlo y confirmarlo
sin-runner nunca arranca: es lo esperado, ningún runner tiene esas labels; cancela el run. Si maquina tampoco arranca, revisa que el repo sea público: en uno privado, los dos jobs gastan minutos de tu plan. gh run view --log no muestra nada mientras el run sigue en curso: cancélalo primero.
Verificado contra la documentación; no ejecutado aquí. Labels, specs, timeouts, límites y comandos de gh verificados contra docs.github.com y cli.github.com en octubre de 2026.
El check se puso rojo: ¿dónde está el error de verdad, qué tipo de falla es, cómo la reproduces en tu máquina y cuándo sirve un re-run?
Sintaxis con variables
gh run list --workflow workflow --branch rama --limit 5 gh run view run_id --log-failed
Leer el log: run rojo → job rojo → paso rojo. El error de verdad está arriba de Process completed with exit code N (N es el código con que terminó el comando; 0 es éxito) y casi siempre es el primero, no el último. --log-failed muestra solo los pasos que fallaron; sin gh, lo mismo en la pestaña Actions.
gh run rerun run_id --failed --debug
Re-run: vuelve a correr lo que falló con el mismo commit (mismo GITHUB_SHA y GITHUB_REF). --debug (en la web, Enable debug logging) suma el log detallado de cada paso y del runner. Si falla igual, no fue mala suerte.
git clone --branch rama url_repo /tmp/ci && cd /tmp/ci/dir pythonversion -m venv .venv && . .venv/bin/activate pip install -r requirements-dev.txt && pytest
Reproducir en local: un clon limpio ve lo mismo que el checkout de CI (solo lo commiteado) y un venv (un entorno de Python aislado) nuevo tiene solo lo que dicen los requirements. Si ahí falla igual, ya no adivinas: lo arreglas en tu máquina.
git bisect start malo bueno git bisect run prueba git bisect reset
¿Qué commit lo rompió? bisect parte la historia a la mitad y corre tu prueba en cada paso: exit 0 = bueno; de 1 a 127 (menos 125) = malo; 125 = no se puede probar, sáltalo. reset te devuelve a tu rama.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| workflow | el archivo del workflow | gateway.yml |
| rama | la rama del PR | feat/json-rapido (ilustrativo) |
| run_id | número del run: sale de gh run list o de la URL del run | 1234567890 |
| url_repo | URL del repo, la misma que usas para clonar | git@github.com:acme/classifier.git (ilustrativo) |
| dir | la carpeta donde el workflow corre los tests | reference/c01/gateway |
| version | la misma versión de Python que pide el workflow | 3.13 |
| malo | un commit donde falla | HEAD |
| bueno | un commit donde pasaba (el del último run verde) | un SHA o un tag |
| prueba | comando que sale con 0 si todo está bien | pytest -q tests/test_api.py |
| commits entre el bueno y el malo | 100 → 7 pruebas; 1,000 → 10 | |
| número de la señal que mató al proceso | 9 = SIGKILL (casi siempre falta de memoria) · 15 = SIGTERM |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Annotations | El resumen de errores y warnings arriba del run, con enlace al paso. Sirve para ubicarte; el detalle está en el log. | Error: Process completed with exit code 1. |
| Process completed with exit code N | El paso terminó con un código distinto de 0. Es la consecuencia: la causa está en las líneas de arriba. | N = 1, 2, 127, 137… |
| pytest: exit 1 · 2 · 4 · 5 | 1: fallaron tests (busca FAILED). 2: interrumpido, también por un error al importar los tests. 4: flags mal usados. 5: no encontró tests. | 1 failed, 23 passed |
| exit 127 · 137 | 127: el shell no encontró el comando (no está instalado o no está en el PATH). 137 = 128 + 9: el proceso murió por SIGKILL, casi siempre por memoria. | command not found |
| bash -e (el default de run) | El paso corta en el primer comando que falla, pero no mira dentro de un pipe (comando1 | comando2): solo cuenta cómo terminó el último. Con shell: bash suma -o pipefail. | pytest | tee log.txt pasa aunque fallen tests |
| Re-run failed jobs | Mismo commit y mismo GITHUB_REF; hasta 30 días después y hasta 50 veces; con los permisos de quien disparó el run original. | gh run rerun ID --failed |
| ACTIONS_STEP_DEBUG · ACTIONS_RUNNER_DEBUG | Secret o variable en true: log detallado en todos los runs, no solo en un re-run. El del runner viene en la carpeta runner-diagnostic-logs al bajar los logs. | aparecen las líneas ##[debug]… |
| ::group:: · ::error file=…,line=…:: | Agrupa líneas del log y crea anotaciones que apuntan a un archivo y una línea. | echo "::error file=app/main.py,line=12::falta MODEL_NAME" |
| $GITHUB_STEP_SUMMARY | Markdown que aparece en la página del run (hasta 1 MiB por paso). Ideal para el resumen de los tests. | echo "### 24 tests ok" >> "$GITHUB_STEP_SUMMARY" |
| fetch-depth: 1 (el default) | actions/checkout baja solo el último commit: git log, git describe o un diff contra main no encuentran historia. fetch-depth: 0 baja todo. En un PR, además, queda en detached HEAD (HEAD apunta a un commit, no a una rama). | with: { fetch-depth: 0 } |
| clon limpio | Reproduce el checkout de CI: solo lo commiteado, sin archivos ignorados ni archivos a los que nunca les hiciste add. | git clone --branch RAMA URL /tmp/ci |
| retención | Logs y artifacts se borran a los 90 días por defecto (1–90 en repos públicos, 1–400 en privados). Desde el 1 de octubre de 2026, la misma regla borra también runs, checks y statuses. Guarda lo que necesites antes. | Settings → Actions → General |
| test flaky | Pasa y falla con el mismo código: orden, tiempo, red, seeds. Un re-run lo tapa, no lo arregla. | falla 1 de cada 10 runs |
| githubstatus.com | Si falla algo que no tocaste (bajar actions, caché, artifacts), y le pasa a otros repos, mira el estado de GitHub antes de depurar. | incidente en Actions |
| Tipo de falla | Cómo se ve | Dónde mirar primero | Arreglo típico |
|---|---|---|---|
| El workflow no arranca | No aparece el run, o aparece con Invalid workflow file y ningún job | on: del YAML; que el archivo esté en .github/workflows en ese commit; la línea y columna del error | Corregir el YAML o el trigger (sección 6) |
| Configuración de una action | Unable to resolve action · Input required and not supplied · Unexpected input(s) | El primer paso rojo, al principio del job | Versión que exista e inputs con el nombre exacto (sección 6) |
| Tu código o tus tests | FAILED tests/… y Process completed with exit code 1 | El resumen de pytest, arriba del exit code | Reproducir con un clon limpio y arreglar |
| Entorno distinto | ModuleNotFoundError, FileNotFoundError, otra versión de Python | Clon limpio y venv nuevo; python --version en el log | Versiones fijas; commitear o generar el archivo |
| Permisos o credenciales | Resource not accessible by integration · Could not assume role with OIDC · no basic auth credentials | permissions: del job; la trust policy del rol; la región | El permiso mínimo que falta (sección 8) o el login a ECR (sección 9) |
| Recursos del runner | exit code 137 · No space left on device · lost communication with the server · exceeded the maximum execution time | free -h y df -h en un paso; la duración del job | Menos memoria o disco, otro runner o un timeout realista (sección 11) |
| Red o servicio externo | Timeouts y errores 5xx de un registry o de PyPI | ¿Falla igual al re-correr? githubstatus.com | Re-run failed jobs; si se repite, no es mala suerte |
| Test flaky | Pasa y falla con el mismo commit | El historial del check en varios runs | Arreglar la causa (orden, tiempo, red, seed), no re-run infinito |
Las primeras dos se ven en segundos y están en el YAML, no en tu código; permisos, recursos y red dependen de la configuración, del runner o de afuera; código, entorno y tests flaky se reproducen y se arreglan en tu máquina.
# El check "tests" del PR se puso rojo; en tu Mac, pytest pasa. 1) Leer el log del paso que falló
gh run list --workflow gateway.yml --branch feat/json-rapido --limit 3
gh run view 1234567890 --log-failed | tail -n 6
# tests Run pytest E ModuleNotFoundError: No module named 'orjson'
# tests Run pytest =========================== short test summary info ===========================
# tests Run pytest ERROR tests/test_api.py
# tests Run pytest !!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
# tests Run pytest 1 error in 0.52s
# tests Run pytest ##[error]Process completed with exit code 2.
# 2) Reproducir como CI: clon limpio y venv nuevo, solo con lo que dicen los requirements
git clone -q --branch feat/json-rapido git@github.com:acme/classifier.git /tmp/ci
cd /tmp/ci/reference/c01/gateway
python3.13 -m venv .venv && . .venv/bin/activate
pip install -q -r requirements-dev.txt && pytest
# … el mismo ModuleNotFoundError: en tu venv de siempre, orjson estaba instalado a mano
# 3) Confirmarlo: la dependencia no está fijada en requirements.txt
grep -n orjson requirements.txt || echo "falta orjson en requirements.txt"
# falta orjson en requirements.txt → agrégala con == (la imagen también la necesita), commit y push--log-failed muestra solo los pasos rojos. La causa es la línea con E (el error de Python), no el exit code del final.exit code 2 con Interrupted: 1 error during collection: pytest ni empezó a correr tests; falló al importar tests/test_api.py, que importa app.main. Un 1 habría sido un test que falló.pip install del workflow. En tu carpeta de siempre pasaba porque orjson ya estaba instalado. Pasa en mi máquina casi siempre es esto, o un archivo que nunca se commiteó (el laboratorio).--debug.docker run --rm -v "$PWD":/src -w /src python:3.13-slim sh -c "pip install -r requirements-dev.txt && pytest".Cómo recordarla
leer → clasificar → reproducir → arreglar. El log dice dónde; el tipo de falla dice quién la arregla; el clon limpio dice si es tu código; el re-run solo sirve para lo intermitente.
Trampa
re-run hasta que pase. Con el mismo commit, una falla de tu código vuelve igual, y si a veces pasa es un test flaky que el re-run esconde. Y no te quedes con la última línea: Process completed with exit code 1 es la consecuencia; la causa está arriba. Ojo también con un pipe: pytest | tee log.txt en un run sin shell: bash deja el paso en verde aunque fallen tests.
En el curso: M10 · CI/CD para modelos: la pirámide de tests M10 · Reproducibilidad: los cinco ingredientes C01 · Archivos de referencia C01 · Modos de falla y degradación
Qué vas a provocar: Reproducir en tu máquina un "pasa en local, falla en CI" con un clon limpio, encontrar por qué con check-ignore y usar git bisect para hallar el commit que rompió una configuración.
Necesitas: Una terminal de macOS, git con user.name y user.email configurados y python3 (cualquier 3.x). Todo es local: no necesita cuenta de GitHub.
mkdir ~/lab-git && cd ~/lab-git && git init -b main && mkdir app tests && echo '*.json' > .gitignore && echo '["bicicletas", "celulares", "zapatos"]' > app/labels.json && printf 'import json\nlabels = json.load(open("app/labels.json"))\nassert len(labels) == 3, labels\nprint("ok:", len(labels), "labels")\n' > tests/test_labels.py && git add . && git commit -m "labels y su test"Qué deberías ver: [main (root-commit) …] labels y su test, 2 files changed: create mode 100644 .gitignore y tests/test_labels.py. app/labels.json no entró y git no avisó: *.json lo ignora.
python3 tests/test_labels.pyQué deberías ver: ok: 3 labels. En tu carpeta pasa: el archivo está en el disco.
git clone -q ~/lab-git ~/lab-git-ci && (cd ~/lab-git-ci && python3 tests/test_labels.py); echo "exit=$?"Qué deberías ver: Traceback …, FileNotFoundError: [Errno 2] No such file or directory: 'app/labels.json' y exit=1. El clon limpio es lo que ve el checkout de CI: solo lo commiteado.
git status --short --ignored && git check-ignore -v app/labels.jsonQué deberías ver: !! app/ (la carpeta entera queda ignorada: su único archivo lo está) y .gitignore:1:*.json seguido de app/labels.json: el archivo de reglas, la línea y el patrón que lo ignoran.
printf '*.json\n!app/labels.json\n' > .gitignore && git add .gitignore app/labels.json && git commit -m "versionar labels.json" && rm -rf ~/lab-git-ci && git clone -q ~/lab-git ~/lab-git-ci && (cd ~/lab-git-ci && python3 tests/test_labels.py)Qué deberías ver: El commit con create mode 100644 app/labels.json y, en el clon nuevo, ok: 3 labels. La regla !app/labels.json es una excepción al *.json.
for i in 1 2 3 4 5 6; do t=150; [ "$i" -ge 4 ] && t=1500; printf 'timeout_ms: %s\nrevision: %s\n' "$t" "$i" > gateway.conf; git add gateway.conf; git commit -qm "cambio $i"; done; git log --oneline -6Qué deberías ver: Seis commits, de cambio 1 a cambio 6. El 4 cambia timeout_ms de 150 a 1500 sin decirlo en el mensaje: es el bug a encontrar.
git bisect start HEAD HEAD~5 && git bisect run grep -q '^timeout_ms: 150$' gateway.confQué deberías ver: Bisecting: 2 revisions left to test after this (roughly 1 step), tres pruebas con running 'grep' … y … is the first bad commit con el mensaje cambio 4, cerrando con bisect found first bad commit. grep sale con 0 si encuentra la línea (bueno) y con 1 si no (malo).
git bisect resetQué deberías ver: Previous HEAD position was … cambio 4 y Switched to branch 'main'. Durante el bisect estuviste en detached HEAD; reset te devuelve a tu rama.
cd ~ && rm -rf ~/lab-git ~/lab-git-ciQué deberías ver: Sin salida.
Cómo arreglarlo y confirmarlo
Si el clon limpio pasa, la diferencia no es un archivo: compara versiones (python --version, pip freeze) o el sistema (corre en un contenedor Linux). Si bisect marca mal un commit, tu prueba no es confiable: debe salir con 0 solo cuando todo está bien, y con 125 cuando ese commit no se puede probar.
Probado de verdad en una carpeta de prueba (git 2.50.1). También con Python 3.14 en macOS y zsh, con HOME en una carpeta temporal.
Lees el mensaje: ¿quién lo dijo (git, GitHub, el runner, una action o AWS), qué significa, qué corres primero y dónde está la explicación?
Sintaxis con variables
fatal: mensaje error: mensaje hint: consejo
Git, en tu máquina. fatal: abortó el comando. error: algo falló. hint: un consejo, casi siempre con el comando que lo arregla. Lee las tres líneas completas.
remote: mensaje ! [remote rejected] rama -> rama (motivo)
GitHub, durante un push o un pull: reglas del repo, permisos, autenticación o un secreto detectado. Las líneas remote: dicen cuál regla; [remote rejected] es el resultado.
Error: mensaje ##[error]mensaje
El runner o una action, en el log de Actions. Aparece también en Annotations. Process completed with exit code N es la consecuencia; la causa está en las líneas de arriba.
Could not assume role with OIDC: motivo An error occurred (codigo) when calling the operacion operation: mensaje
AWS. La primera forma la imprime configure-aws-credentials con lo que respondió AWS STS (el servicio que entrega credenciales temporales); la segunda, la AWS CLI dentro de un run.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| mensaje | el texto que explica qué pasó | Need to specify how to reconcile divergent branches. |
| consejo | la sugerencia de git, con el comando | use 'git pull' before pushing again. |
| rama | la rama que empujabas | main |
| motivo | la razón corta del rechazo o del fallo | pre-receive hook declined · Not authorized to perform sts:AssumeRoleWithWebIdentity |
| codigo | el código de error de AWS | AccessDenied, ExpiredToken |
| operacion | la operación de AWS que falló | AssumeRoleWithWebIdentity, GetAuthorizationToken |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| fatal: | Git abortó el comando: no hizo el cambio. La misma línea dice por qué. | fatal: Need to specify how to reconcile divergent branches. |
| error: · hint: | error: algo falló. hint: un consejo con el comando que lo arregla. | hint: use 'git pull' before pushing again. |
| ! [rejected] | Tu push no avanza el remoto: (fetch first) o (non-fast-forward). Integra primero. | git pull --rebase |
| ! [remote rejected] · remote: | Lo rechazó GitHub, no Git: una regla del repo, permisos o un secreto. | remote: error: GH013 … |
| CONFLICT (…) | Git no pudo juntar dos cambios. content: mismas líneas. modify/delete: uno borró y el otro editó. | CONFLICT (modify/delete): … deleted in otra and modified in HEAD. |
| Warning: · Note: | Avisos: no fallaron, pero te dicen algo que puedes perder (commits sueltos, detached HEAD). | Warning: you are leaving 1 commit behind |
| Invalid workflow file · The workflow is not valid | El YAML no valida: el run no arranca. El mensaje trae línea y columna. | (Line: 12, Col: 5): Unexpected value 'runs_on' |
| Error: (anotación) | Un paso falló o una action llamó a setFailed: aparece en Annotations y en el log. | Error: Input required and not supplied: aws-region |
| Warning: Unexpected input(s) | Pasaste en with: un input que la action no declara (un typo). Se ignora sin fallar. | Unexpected input(s) 'python_version', valid inputs are […] |
| Process completed with exit code N | El paso terminó con N distinto de 0: mira arriba. | exit code 2 en pytest: error al importar los tests |
| Could not assume role with OIDC: | configure-aws-credentials no consiguió el rol; lo que sigue lo dijo AWS STS. | Not authorized to perform sts:AssumeRoleWithWebIdentity |
| An error occurred (…) when calling the … operation: | La AWS CLI dentro de un run. El código entre paréntesis dice el tipo de error. | (AccessDenied) when calling the AssumeRoleWithWebIdentity operation |
| no basic auth credentials · tag invalid: | Salen en un docker push a ECR. El primero lo dice docker: no tiene un login válido para ese registry. El segundo lo responde ECR: ese tag ya existe en un repo configurado con tags inmutables. | no basic auth credentials |
| Waiting for … · Canceling since … | No es un error de tu código: GitHub espera un runner, una aprobación o su turno en un grupo de concurrency. | Waiting for a runner to pick up this job... |
Quiz · un mensaje a la vez
Mensaje 1 de 26
fatal: not a git repository (or any of the parent directories): .git
26 de 26
fatal: not a git repository (or any of the parent directories): .git
Corriste git en una carpeta que no es parte de un repo: ni ella ni las de arriba tienen .git. Pasa en una terminal nueva o fuera del proyecto.
Primero: cd ~/code/classifier && git status (entra a la carpeta del repo)
→ Sección 2 · El día a día: clone, status, add, commit, diff, log, push, pull, fetch y .gitignore
fatal: pathspec 'gateway/app/mian.py' did not match any files
git add no encontró ese archivo: la ruta tiene un error de tipeo o estás en otra carpeta (las rutas son relativas a donde estás).
Primero: git status --short (los archivos con cambios, con su ruta real)
→ Sección 2 · El día a día: clone, status, add, commit, diff, log, push, pull, fetch y .gitignore
! [rejected] main -> main (fetch first) · ! [rejected] main -> main (non-fast-forward)
El remoto tiene commits que tú no tienes: alguien empujó antes, y Git no deja que tu push los borre. fetch first: todavía no los bajaste; non-fast-forward: ya los bajaste, pero tu rama no los incluye.
Primero: git pull --rebase (o --no-rebase), corre los tests y git push
→ Sección 2 · El día a día: clone, status, add, commit, diff, log, push, pull, fetch y .gitignore
fatal: Need to specify how to reconcile divergent branches.
Tu rama y la remota tienen commits distintos (divergieron) y no configuraste cómo juntarlas al hacer pull. Git no elige por ti: merge o rebase.
Primero: git pull --rebase (o git config pull.rebase true para que sea tu default)
→ Sección 2 · El día a día: clone, status, add, commit, diff, log, push, pull, fetch y .gitignore
CONFLICT (content): Merge conflict in k8s/gateway.yaml · Automatic merge failed; fix conflicts and then commit the result.
Las dos ramas cambiaron las mismas líneas y Git no sabe cuál dejar. Marcó el archivo con <<<<<<<, ======= y >>>>>>>: eliges tú, guardas y haces add.
Primero: git status (los archivos en both modified)
→ Sección 3 · Ramas, merge y rebase; conflictos y cómo resolverlos
error: the branch 'fix/cpu' is not fully merged
git branch -d no borra una rama cuyos commits no llegaron a tu rama actual (o a su rama remota, si la sigue): los perderías. Pasa, por ejemplo, después de un Squash and merge en GitHub: main recibió un commit nuevo con tus cambios, no tus commits.
Primero: git log --oneline main..fix/cpu (qué commits tiene la rama que main no); si ya entraron con el squash, git branch -D fix/cpu
→ Sección 3 · Ramas, merge y rebase; conflictos y cómo resolverlos
error: Your local changes to the following files would be overwritten by checkout: · Please commit your changes or stash them before you switch branches.
Tienes cambios sin commit en un archivo que la otra rama tiene distinto: cambiar de rama los pisaría, así que Git no cambia. git switch también dice checkout.
Primero: git stash push -m "wip" (y, ya en la otra rama, git stash pop)
→ Sección 4 · Deshacer: restore, reset, revert, amend, stash y reflog
Warning: you are leaving 1 commit behind, not connected to any of your branches:
Hiciste commits en detached HEAD y te fuiste a una rama: esos commits quedan sueltos. No se borraron: el aviso trae el SHA y el reflog los guarda unos 30 días (lo que ninguna rama alcanza vence antes que el resto).
Primero: git branch rescate SHA_DEL_AVISO
→ Sección 4 · Deshacer: restore, reset, revert, amend, stash y reflog
fatal: The current branch fix/timeout has no upstream branch.
Tu rama nueva todavía no existe en el remoto y no sabe a qué rama empujar. El primer push la crea allá y la enlaza (su upstream).
Primero: git push -u origin fix/timeout
→ Sección 5 · GitHub: remotos, acceso, pull requests y reglas de la rama
remote: error: GH013: Repository rule violations found for refs/heads/main.
GitHub rechazó el push por una regla del repo (un ruleset): por ejemplo, main solo acepta cambios por PR con checks en verde, o push protection (el detector de secretos de GitHub) encontró un secreto. Las líneas remote: que siguen dicen qué regla. GH006 es el mismo rechazo con la protección de ramas clásica.
Primero: git switch -c fix/mi-cambio && git push -u origin fix/mi-cambio (y abre un PR)
→ Sección 5 · GitHub: remotos, acceso, pull requests y reglas de la rama
remote: Support for password authentication was removed on August 13, 2021. · fatal: Authentication failed for 'https://github.com/…'
Usaste tu contraseña de GitHub cuando git la pidió por HTTPS. GitHub ya no la acepta para git: usa SSH, un personal access token con un credential helper, o gh auth login.
Primero: git remote -v (si dice https://, entra con gh auth login o un token; si prefieres SSH, cambia la URL)
→ Sección 5 · GitHub: remotos, acceso, pull requests y reglas de la rama
git@github.com: Permission denied (publickey). · fatal: Could not read from remote repository.
GitHub no aceptó ninguna llave SSH tuya: no tienes una cargada, no la subiste a tu cuenta o se ofrece otra. Could not read from remote repository también sale si el repo no existe o no tienes acceso.
Primero: ssh -T git@github.com (si todo está bien, responde Hi TU_CUENTA! …)
→ Sección 5 · GitHub: remotos, acceso, pull requests y reglas de la rama
The workflow is not valid. .github/workflows/gateway.yml (Line: 12, Col: 5): Unexpected value 'runs_on'
GitHub no pudo validar el YAML: una clave mal escrita o en el nivel equivocado (runs_on en vez de runs-on), o mala indentación. El run falla al arrancar y no corre ningún job.
Primero: git diff HEAD~1 -- .github/workflows/gateway.yml (qué cambió en el YAML)
Unable to resolve action `actions/checkout@v9`, unable to find version `v9`
La action existe, pero esa versión (tag o rama) no. Pasa con un error en el número, o al copiar un ejemplo que pide una versión que no existe.
Primero: git ls-remote --tags https://github.com/actions/checkout (los tags que existen)
The version '3.1' with architecture 'x64' was not found for Ubuntu 24.04.
setup-python no encontró esa versión. El clásico: python-version: 3.10 sin comillas; YAML lo lee como el número 3.1. Escribe las versiones entre comillas: '3.10', '3.13'.
Primero: grep -n 'python-version' .github/workflows/*.yml
→ Sección 7 · Tests en CI: Python, caché, matrix y checks obligatorios
Resource not accessible by integration
El GITHUB_TOKEN del job no tiene permiso para esa llamada a la API de GitHub (HTTP 403): comentar el PR, crear un release, escribir checks. En repos personales y organizaciones nuevas, por defecto solo puede leer contents y packages; en un PR desde un fork, siempre es de solo lectura.
Primero: grep -n -A4 'permissions' .github/workflows/*.yml
→ Sección 8 · Secretos, permisos y OIDC: entrar a AWS sin llaves
Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers
configure-aws-credentials no consiguió ninguna credencial. Con role-to-assume y OIDC, casi siempre falta permissions: id-token: write en el job; unas líneas antes, el mismo paso pregunta: Did you mean to set the id-token permission?
Primero: grep -n 'id-token' .github/workflows/*.yml
→ Sección 8 · Secretos, permisos y OIDC: entrar a AWS sin llaves
Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity
AWS recibió el token de GitHub, pero la trust policy del rol (la regla que dice qué tokens pueden asumirlo) no lo acepta: el sub (quién pide) o el aud (para quién es) no coinciden. Típico: el job usa environment (el sub termina en :environment:produccion), corre en un PR (:pull_request) o el repo se creó, renombró o transfirió después del 15 de julio de 2026 y su sub trae IDs (repo:acme@123456/classifier@456789:…).
Primero: aws iam get-role --role-name gha-classifier-deploy --query Role.AssumeRolePolicyDocument
→ Sección 8 · Secretos, permisos y OIDC: entrar a AWS sin llaves
no basic auth credentials
docker push o pull contra ECR sin un login válido: no corriste el login, lo hiciste en otra región (el token es por región), pasaron las 12 h que dura o tu identidad no tiene permiso sobre ese repo.
Primero: aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
tag invalid: The image tag '3f9c2ab' already exists in the 'classifier-gateway' repository and cannot be overwritten because the repository is immutable.
El repo de ECR está configurado con tags inmutables (el default de ECR es mutable) y ese tag ya existe (en la API, ImageTagAlreadyExistsException). Es bueno: nadie pisa una versión. Pasa con :latest o al re-correr todo el workflow del mismo commit.
Primero: aws ecr describe-images --repository-name classifier-gateway --image-ids imageTag=3f9c2ab
error: deployment "gateway" exceeded its progress deadline
kubectl rollout status, el último paso del job deploy, falló: los pods nuevos no quedaron listos en progressDeadlineSeconds (600 s sin avance, por defecto). El job queda rojo, pero Kubernetes no vuelve atrás solo: con maxUnavailable: 0, los pods viejos siguen sirviendo. Si vence antes el --timeout del paso, el mensaje es otro, pero la causa es la misma.
Primero: kubectl -n classifier get pods -l app.kubernetes.io/name=gateway (cuáles no están Ready y por qué)
→ Sección 10 · CD: GitOps o kubectl, aprobación, concurrency y rollback con revert
Canceling since a higher priority waiting request for 'deploy-gateway-produccion' exists
concurrency canceló un run que esperaba: en ese grupo solo espera uno, y llegó uno más nuevo. No es un error del código. Si cada deploy tiene que correr, usa queue: max; nunca cancel-in-progress: true en un deploy.
Primero: gh run list --workflow gateway.yml --limit 5 (cuál quedó y cuál se canceló)
→ Sección 10 · CD: GitOps o kubectl, aprobación, concurrency y rollback con revert
GitHub Actions is not permitted to create or approve pull requests.
El workflow quiso abrir un PR (el del manifest) con GITHUB_TOKEN y el repo no lo permite: Allow GitHub Actions to create and approve pull requests está apagado, que es el default en repos personales y en organizaciones nuevas. Con un token de GitHub App no hace falta esa opción.
Primero: gh api repos/OWNER/REPO/actions/permissions/workflow (mira can_approve_pull_request_reviews)
→ Sección 10 · CD: GitOps o kubectl, aprobación, concurrency y rollback con revert
Waiting for a runner to pick up this job...
Ningún runner libre tiene todas las labels que pide runs-on: el self-hosted está apagado u offline, las labels no coinciden, o llegaste al límite de jobs en paralelo. Con self-hosted, a las 24 h en cola, el job falla.
Primero: gh api repos/OWNER/REPO/actions/runners --jq '.runners[] | .name + " " + .status'
→ Sección 11 · Runners: GitHub-hosted o self-hosted (la T4 del C01), labels, timeouts y costos
The job running on runner GitHub Actions 7 has exceeded the maximum execution time of 360 minutes.
El job llegó a su timeout-minutes (360 si no pusiste nada, que también es el máximo en GitHub-hosted) y GitHub lo canceló. Algo se colgó (un test esperando la red, un prompt interactivo) o el job es demasiado grande para ese runner.
Primero: gh run view RUN_ID --log | tail -n 50 (qué hacía al final)
→ Sección 11 · Runners: GitHub-hosted o self-hosted (la T4 del C01), labels, timeouts y costos
Error: Process completed with exit code 1.
Un comando del paso terminó con un código distinto de 0 y el paso falló. No es la causa: la causa está en las líneas de arriba (en pytest, el resumen FAILED; exit 2 = error al importar los tests; 5 = no encontró tests; 127 = comando no encontrado; 137 = lo mató la memoria).
Primero: gh run view RUN_ID --log-failed
→ Sección 12 · Diagnóstico: leer el log, re-run con debug, reproducir en local, tipos de falla
Ningún mensaje tiene ese texto. Prueba con una palabra del mensaje (por ejemplo rejected o OIDC).
Run aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-classifier-deploy
aws-region: us-east-1
…
Assuming role with OIDC
…
Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentityRun … y with: los imprime el runner; Assuming role with OIDC, la action; lo que sigue a Could not assume role with OIDC: lo dijo AWS STS.Not authorized, y no Credentials could not be loaded: el token OIDC sí existe (el id-token: write está bien) y AWS lo rechazó. Casi siempre, la trust policy del rol (la regla que dice qué tokens pueden asumirlo) no acepta ese sub (quién pide el token) o ese aud (para quién es).environment: produccion, así que su sub es repo:acme/classifier:environment:produccion, no …:ref:refs/heads/main. Con un environment, el sub por defecto no trae la rama.sub trae además los IDs: repo:acme@123456/classifier@456789:…. Una trust policy copiada de un tutorial viejo falla igual.aws iam get-role --role-name gha-classifier-deploy --query Role.AssumeRolePolicyDocument. El arreglo va en AWS (la trust policy), no en el workflow.Cómo recordarla
quién habla dice dónde mirar. fatal, error y hint: git en tu máquina. remote: y [remote rejected]: GitHub. Error: en el log: el runner o una action. Could not assume role o An error occurred (…): AWS. Waiting o Canceling: GitHub esperando algo, no un bug.
Trampa
leer solo la última línea. Process completed with exit code 1 y error: failed to push some refs son consecuencias: la causa está arriba (el FAILED de pytest, el hint de git, las líneas remote:). Y los textos cambian con la versión: git reescribe sus hints y las actions sus mensajes entre releases; busca la parte central, no la línea entera.
En el curso: M10 · CI/CD para modelos: la pirámide de tests M5 · Registries y tags inmutables M15 · Secretos y credenciales C01 · Modos de falla y degradación
Qué vas a provocar: Reconocer los 26 mensajes en 5 minutos y provocar cuatro de git en tu máquina: un push rechazado, un pull que no sabe cómo juntar, un conflicto y una rama que git branch -d no quiere borrar.
Necesitas: Una terminal de macOS (zsh o bash; sort -R y sed -i '' son las formas de macOS) y git con user.name y user.email configurados. Todo es local.
ronda() { printf '%s\n' 'fatal: not a git repository (or any of the parent directories): .git' "fatal: pathspec 'gateway/app/mian.py' did not match any files" '! [rejected] main -> main (fetch first)' 'fatal: The current branch fix/timeout has no upstream branch.' 'remote: error: GH013: Repository rule violations found for refs/heads/main.' 'fatal: Need to specify how to reconcile divergent branches.' 'CONFLICT (content): Merge conflict in k8s/gateway.yaml' 'error: Your local changes to the following files would be overwritten by checkout' "error: the branch 'fix/cpu' is not fully merged" 'Warning: you are leaving 1 commit behind, not connected to any of your branches' 'remote: Support for password authentication was removed on August 13, 2021.' 'git@github.com: Permission denied (publickey).' 'error: deployment "gateway" exceeded its progress deadline' "The workflow is not valid. (Line: 12, Col: 5): Unexpected value 'runs_on'" 'Unable to resolve action `actions/checkout@v9`, unable to find version `v9`' "The version '3.1' with architecture 'x64' was not found for Ubuntu 24.04." 'Resource not accessible by integration' 'Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers' 'Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity' 'no basic auth credentials' "tag invalid: The image tag '3f9c2ab' already exists in the 'classifier-gateway' repository and cannot be overwritten because the repository is immutable." "Canceling since a higher priority waiting request for 'deploy-gateway-produccion' exists" 'GitHub Actions is not permitted to create or approve pull requests.' 'Waiting for a runner to pick up this job...' 'The job running on runner GitHub Actions 7 has exceeded the maximum execution time of 360 minutes.' 'Error: Process completed with exit code 1.' | sort -R | head -5; }; rondaQué deberías ver: Cinco mensajes al azar. Para cada uno, en menos de 30 s y en voz alta: quién lo dijo (git, GitHub, el runner, una action o AWS), la causa probable y el primer comando. Después compáralo con la lista.
rondaQué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.
git init --bare -b main ~/lab-git-remote.git && git clone -q ~/lab-git-remote.git ~/lab-git && cd ~/lab-git && echo "replicas: 3" > gateway.yaml && git add . && git commit -qm "replicas 3" && git push -q -u origin main && git clone -q ~/lab-git-remote.git ~/lab-git-bQué deberías ver: Initialized empty Git repository … y warning: You appear to have cloned an empty repository. (el primer clon, antes del primer commit). Quedan dos clones del mismo remoto: tú en ~/lab-git y alguien más en ~/lab-git-b.
(cd ~/lab-git-b && sed -i '' 's/3/4/' gateway.yaml && git commit -qam "replicas 4" && git push -q) && sed -i '' 's/3/6/' gateway.yaml && git commit -qam "replicas 6" && git pushQué deberías ver: ! [rejected] main -> main (fetch first), error: failed to push some refs to … y el hint Updates were rejected because the remote contains work that you do not have locally.
git pullQué deberías ver: hint: You have divergent branches and need to specify how to reconcile them. … y fatal: Need to specify how to reconcile divergent branches.
git pull --no-rebase; git status --short; git merge --abortQué deberías ver: CONFLICT (content): Merge conflict in gateway.yaml, Automatic merge failed; fix conflicts and then commit the result. y UU gateway.yaml. merge --abort deja todo como antes del pull.
git switch -qc fix/cpu && echo "cpu: 1" >> gateway.yaml && git commit -qam "cpu 1" && git switch -q main && git merge -q --squash fix/cpu && git commit -qm "cpu 1 (#9)" && git branch -d fix/cpu; git branch -D fix/cpuQué deberías ver: Squash commit -- not updating HEAD; después error: the branch 'fix/cpu' is not fully merged, con el hint de git branch -D, y al final Deleted branch fix/cpu (was …). main ya tiene el cambio (en el commit squash), pero no tus commits: por eso -d se niega.
cd ~ && rm -rf ~/lab-git ~/lab-git-b ~/lab-git-remote.gitQué deberías ver: Sin salida.
Cómo arreglarlo y confirmarlo
Los que fallaste en la ronda: abre la sección de su tema (el enlace de la lista) y haz su laboratorio. Repite otro día: la meta es 5 de 5 en menos de 2 minutos.
Probado de verdad en una carpeta de prueba (git 2.50.1). En zsh y macOS, con HOME en una carpeta temporal y un repo bare local como remoto. Los mensajes de git de la ronda salen de corridas locales; los de Actions, AWS y kubectl, de docs.github.com, el código de las actions, la documentación de AWS y el código de kubectl.
Del síntoma al primer comando, y una revisión rápida de tu workflow antes del push.
Elige lo que ves. Te dice qué correr primero y las causas típicas, cada una con cómo comprobarla y cómo arreglarla.
Elige un síntoma para ver las causas.
git push falla con ! [rejected] o ! [remote rejected].
Corre primero
git fetch origin && git status
Alguien empujó antes que tú: el remoto tiene commits que no tienes (fetch first) o que bajaste pero no integraste (non-fast-forward).
Compruébalo: git fetch origin && git status (dice behind o have diverged)
Arreglo: git pull --rebase (o --no-rebase), resuelve si hay conflictos, corre los tests y vuelve a hacer push. Nunca --force en una rama compartida.
Reescribiste commits que ya estaban en el remoto (amend, rebase, reset) en tu rama del PR.
Compruébalo: git log --oneline origin/RAMA..HEAD (lo que solo tienes tú) · git log --oneline HEAD..origin/RAMA (lo que solo tiene el remoto)
Arreglo: Si la rama es solo tuya: git push --force-with-lease, que se niega si el remoto cambió desde tu último fetch. En main o en una rama compartida, nunca.
Una regla del repo lo bloquea: main pide PR y checks (remote: error: GH013 … o GH006 …, y [remote rejected]).
Compruébalo: las líneas remote: (dicen qué regla) · Settings → Rules → Rulesets
Arreglo: Empuja a una rama y abre un PR. Si es CI quien empuja a main, que abra un PR (sección 10).
Push protection encontró un secreto en tus commits: las líneas remote: dicen el tipo, el commit y el archivo.
Compruébalo: las líneas remote: con commit y path · git log -p -1 (si es el último commit)
Arreglo: Sácalo de los commits antes de empujar: git commit --amend si es el último; git rebase -i SHA~1 si es anterior. Si la credencial es real, rótala.
Un merge, rebase, pull o stash pop se detuvo con CONFLICT.
Corre primero
git status
Las dos ramas cambiaron las mismas líneas (o líneas pegadas) y Git no adivina cuál dejar.
Compruébalo: git status (both modified) · git diff (las marcas <<<<<<<, ======= y >>>>>>>)
Arreglo: Edita el archivo: deja la versión correcta y borra las marcas. git add ARCHIVO y después git commit (merge) o git rebase --continue.
Estás en medio de un rebase: tus commits se aplican de a uno y puede haber un conflicto en cada uno.
Compruébalo: git status (interactive rebase in progress; onto …)
Arreglo: Resuelve, git add y git rebase --continue. Para volver al estado de antes: git rebase --abort.
Uno borró el archivo y el otro lo editó (CONFLICT (modify/delete)).
Compruébalo: git status (deleted by them o deleted by us)
Arreglo: Decide: git rm ARCHIVO si debe irse, o git add ARCHIVO si se queda.
No querías juntar todavía y prefieres volver atrás.
Compruébalo: git status (dice qué operación está en curso)
Arreglo: git merge --abort o git rebase --abort. Después de un stash pop con conflicto, el stash no se borra (The stash entry is kept in case you need it again.): sigue en git stash list.
Perdí commits: después de un reset --hard, un rebase o de borrar una rama, ya no aparecen.
Corre primero
git reflog -20
Un git reset --hard movió la rama hacia atrás.
Compruébalo: git reflog (la línea anterior a reset: moving to …)
Arreglo: git branch rescate HEAD@{1} (o el SHA que viste) y revisa; si quieres volver, git reset --hard ESE_SHA.
Hiciste commits en detached HEAD y te fuiste a una rama (Warning: you are leaving 1 commit behind…).
Compruébalo: el aviso trae el SHA · git reflog
Arreglo: git branch rescate SHA
Borraste una rama con git branch -D.
Compruébalo: la línea Deleted branch x (was abc1234). · git reflog
Arreglo: git branch x abc1234
Un rebase o un amend reescribió commits: la rama apunta a copias nuevas y las viejas quedaron sueltas.
Compruébalo: git reflog (rebase (start) … o commit (amend) …)
Arreglo: Tras un rebase, git reset --hard ORIG_HEAD apenas termina; tras un amend, git reset --hard HEAD@{1} (amend no deja ORIG_HEAD); más tarde, el SHA del reflog. Las entradas duran 90 días (30 las de commits que ninguna rama alcanza).
Hice commit en la rama equivocada (por ejemplo, directo en main), o estoy por hacerlo.
Corre primero
git status -sb && git log --oneline -3
Todavía no hiciste push: los commits solo están en tu main local.
Compruébalo: git status -sb (## main...origin/main [ahead 1])
Arreglo: Con el árbol limpio: git branch fix/x (guarda los commits en una rama nueva), git reset --hard origin/main (main vuelve a ser como el remoto) y git switch fix/x.
Los commits iban en otra rama que ya existe.
Compruébalo: git log --oneline -3 (anota los SHA)
Arreglo: git switch otra-rama && git cherry-pick SHA… y después sácalos de la equivocada: reset si no hiciste push; revert si ya lo hiciste.
Ya hiciste push a una rama compartida.
Compruébalo: git log --oneline origin/main -3
Arreglo: No reescribas lo publicado: git revert SHA en esa rama y rehaz el cambio en una rama con PR. Con main protegida, GitHub ni siquiera acepta el push directo.
Los cambios están sin commit y estás en la rama equivocada.
Compruébalo: git status
Arreglo: git switch -c rama-correcta (los cambios sin commit viajan contigo) o git stash push y git stash pop en la otra rama.
Subí un secreto (una llave de AWS, un token) a git.
Corre primero
git log --all --oneline -S 'PARTE_DEL_SECRETO'
Ya está en GitHub: hiciste push. Aunque lo borres en el commit siguiente, sigue en la historia, en los clones y en los forks.
Compruébalo: git log --all --oneline -S 'PARTE_DEL_SECRETO' (en qué commits aparece)
Arreglo: Primero revoca o rota la credencial en su servicio (IAM, GitHub): eso la deja inútil. Después, si hace falta, reescribe la historia con git filter-repo (una herramienta aparte, 2.47 o más nueva) y --sensitive-data-removal, y pide a GitHub Support que limpie lo que queda en caché.
Push protection lo frenó: no llegó a GitHub.
Compruébalo: las líneas remote: dicen el tipo de secreto, el commit y el archivo
Arreglo: Si está en el último commit: sácalo y git commit --amend --all. Si está en uno anterior: git rebase -i SHA~1, marca edit, sácalo, git add, git commit --amend y git rebase --continue. Después, push.
Todavía no hiciste push.
Compruébalo: git status -sb ([ahead N])
Arreglo: Corrige el commit (amend o rebase -i) antes de empujar y agrega el archivo a .gitignore (.env, *.pem).
Lo pusiste en un workflow: en el YAML o en un echo que lo imprime.
Compruébalo: grep -rn 'AKIA' .github/workflows · los logs de ese run
Arreglo: Rótalo, muévelo a un secret o, mejor, usa OIDC (sección 8); nunca hagas echo de un secret. Borra los logs de ese run (Delete all logs).
Hice push o abrí el PR y el workflow no arrancó.
Corre primero
git ls-tree -r --name-only HEAD .github/workflows
El archivo no está donde GitHub lo busca: tiene que estar en .github/workflows/, terminar en .yml o .yaml y estar en ese commit (para workflow_dispatch y schedule, en la rama por defecto).
Compruébalo: git ls-tree -r --name-only HEAD .github/workflows
Arreglo: Muévelo a .github/workflows/, commit y push.
El trigger no coincide: on.push.branches no incluye tu rama, un filtro paths deja afuera lo que cambiaste, o el commit trae [skip ci].
Compruébalo: el bloque on: del YAML · git log -1 --format=%B (el mensaje del commit)
Arreglo: Ajusta branches o paths, o haz otro commit sin [skip ci].
El YAML no valida: el run aparece con Invalid workflow file y no corre ningún job.
Compruébalo: gh run list --workflow ARCHIVO --limit 3 · la pestaña Actions (el error trae línea y columna)
Arreglo: Corrige esa línea (indentación, una clave mal escrita). El revisor de esta página lo atrapa antes del push.
Lo disparó un workflow con GITHUB_TOKEN: un push así no crea runs, y un PR abierto así deja sus runs esperando aprobación. Lo mismo pasa con el PR de un fork de alguien que contribuye por primera vez.
Compruébalo: ¿lo abrió github-actions[bot]? · el botón Awaiting approval en el PR
Arreglo: Alguien con permiso de escritura (write) aprieta Approve workflows to run. Para los PRs de CI, un token de GitHub App: con él, los runs arrancan solos.
Los tests pasan en mi máquina pero fallan en CI.
Corre primero
gh run view RUN_ID --log-failed
Una dependencia que instalaste a mano y no está en los requirements (ModuleNotFoundError en CI).
Compruébalo: python3.13 -m venv /tmp/v && /tmp/v/bin/pip install -r requirements-dev.txt && /tmp/v/bin/python -m pytest (un venv nuevo)
Arreglo: Agrégala con versión fija (==) a requirements.txt; la imagen también la necesita.
Un archivo que no está en git: nunca le hiciste add, o .gitignore lo ignora (FileNotFoundError en CI).
Compruébalo: git status --short --ignored · git check-ignore -v RUTA · los tests en un clon limpio
Arreglo: Commitéalo (con una excepción !ruta en .gitignore) o genéralo dentro del job. Los artefactos (.onnx, .plan) se generan; no se commitean.
Otra versión de Python o de un paquete: tu Mac tiene 3.14 y el workflow pide 3.13; sin versiones fijas, pip baja otras.
Compruébalo: python3 --version en los dos lados · un paso pip freeze en el job contra tu pip freeze
Arreglo: Fija python-version en el workflow, entre comillas ('3.13'), y todas las dependencias con ==.
Diferencias del sistema: Linux distingue mayúsculas en los nombres de archivo (el disco de macOS, por defecto, no), y cambian la zona horaria, el idioma y las rutas.
Compruébalo: git ls-files | grep -i NOMBRE (el nombre exacto, con mayúsculas)
Arreglo: Usa el nombre exacto en imports y rutas; si un test depende de la hora o del idioma, fíjalos en el test. Para Linux como en CI, corre los tests en un contenedor.
Falla el paso de OIDC (configure-aws-credentials) o el login a ECR.
Corre primero
gh run view RUN_ID --log-failed
El job no tiene permissions: id-token: write, así que no hay token OIDC: Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers (unas líneas antes, el mismo paso pregunta: Did you mean to set the id-token permission?).
Compruébalo: grep -n 'id-token' .github/workflows/*.yml
Arreglo: permissions: { id-token: write, contents: read } en el job que asume el rol.
La trust policy (la regla del rol que dice qué tokens pueden asumirlo) no acepta este token: el sub no coincide (Not authorized to perform sts:AssumeRoleWithWebIdentity). Típico: el job usa environment (el sub termina en :environment:produccion), corre en un PR (:pull_request) o el repo se creó, renombró o transfirió después del 15 de julio de 2026 y su sub trae IDs (repo:acme@123456/classifier@456789:…).
Compruébalo: aws iam get-role --role-name ROL --query Role.AssumeRolePolicyDocument (desde tu máquina, con permisos de IAM; el rol del job: gha-classifier-gateway en imagen, gha-classifier-deploy en deploy)
Arreglo: Ajusta la condición token.actions.githubusercontent.com:sub al formato real; aud tiene que ser sts.amazonaws.com.
La cuenta no tiene el proveedor OIDC de GitHub (No OpenIDConnect provider found in your account for https://token.actions.githubusercontent.com).
Compruébalo: aws iam list-open-id-connect-providers
Arreglo: Crea el proveedor con la URL https://token.actions.githubusercontent.com y la audiencia sts.amazonaws.com: una vez por cuenta.
El rol se asumió, pero ECR no deja subir: falta ecr:GetAuthorizationToken o los permisos de subida al repo, o la región no es la del repo (no basic auth credentials o un 403).
Compruébalo: aws ecr describe-repositories --repository-names classifier-gateway --region us-east-1
Arreglo: Policy del rol con ecr:GetAuthorizationToken (sobre *) y los permisos de subida limitados al repo (BatchCheckLayerAvailability, InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload, PutImage y BatchGetImage); la misma región en aws-region y en el registry.
La imagen no llega a ECR, o llega con la arquitectura equivocada.
Corre primero
aws ecr describe-images --repository-name classifier-gateway --image-ids imageTag=TAG
docker/build-push-action con push en su default (false): construye y no sube nada.
Compruébalo: grep -n 'push:' .github/workflows/gateway.yml
Arreglo: push: true, con tags que lleven el registry completo (123456789012.dkr.ecr.us-east-1.amazonaws.com/classifier-gateway:TAG).
Ese tag ya existe y el repo está configurado con tags inmutables (en ECR es opcional; el default es mutable): tag invalid: … cannot be overwritten because the repository is immutable. Pasa con :latest o al re-correr todo el workflow del mismo commit.
Compruébalo: aws ecr describe-images --repository-name classifier-gateway --image-ids imageTag=TAG (si aparece, ya se subió antes)
Arreglo: Tag = SHA de un commit nuevo. Para re-correr, Re-run failed jobs (no all jobs), o salta el push si el tag ya existe.
La construiste en tu Mac (arm64) y la subiste a mano: los nodos amd64 no la pueden correr (no match for platform in manifest o exec format error).
Compruébalo: docker buildx imagetools inspect 123456789012.dkr.ecr.us-east-1.amazonaws.com/classifier-gateway:TAG (lista las plataformas)
Arreglo: Que la construya CI con platforms: linux/amd64. Nunca push desde la laptop.
Se construyó en un runner arm64 (ubuntu-24.04-arm o un self-hosted Graviton) sin platforms.
Compruébalo: el runs-on del job · uname -m en un paso (aarch64)
Arreglo: platforms: linux/amd64 explícito (en un runner arm64 necesita emulación con QEMU: docker/setup-qemu-action) o un runner x64.
El workflow tarda mucho y la caché nunca acierta.
Corre primero
gh run view RUN_ID --log | grep -iE 'cache restored|cache not found|cache is not found'
No hay caché: setup-python sin cache: pip, o buildx sin cache-from y cache-to.
Compruébalo: grep -nE 'cache' .github/workflows/*.yml
Arreglo: cache: pip con cache-dependency-path: reference/c01/gateway/requirements*.txt, y cache-from: type=gha con cache-to: type=gha,mode=max.
La key cambia en cada run: incluye github.sha, la fecha o un archivo que cambia siempre (Cache not found for input keys: …).
Compruébalo: el log del paso de caché: Cache not found for input keys: …
Arreglo: Que la key sea el hash de los requirements (hashFiles('**/requirements*.txt')), con restore-keys para aciertos parciales.
La caché no se ve desde tu rama: un run restaura la de su rama, la de la rama por defecto o, en un PR, la de la base; nunca la de ramas hermanas. Y se borra si nadie la usa en 7 días, o cuando el repo pasa de su límite, 10 GB por defecto (primero las que llevan más tiempo sin usarse).
Compruébalo: gh cache list (key, tamaño y último uso)
Arreglo: Que main corra el workflow y cree la caché que heredan los PRs; keys más chicas.
El Dockerfile invalida la capa de dependencias: COPY . antes de pip install.
Compruébalo: el log de buildx: qué pasos dicen CACHED
Arreglo: Primero COPY requirements.txt y pip install; después el código, como el Dockerfile del gateway del C01.
El deploy dejó una versión equivocada, o el cluster sigue con la vieja.
Corre primero
kubectl get deploy gateway -n classifier -o jsonpath='{.spec.template.spec.containers[0].image}'
El deploy nunca llegó. Con GitOps: el PR del manifest no se mergeó, o se mergeó uno más viejo. Con el job de kubectl: quedó en Waiting (nadie aprobó), concurrency lo canceló por uno más nuevo, o falló el rollout.
Compruébalo: gh run list --workflow gateway.yml --limit 5 · git show origin/main:reference/c01/k8s/gateway.yaml | grep image:
Arreglo: Aprueba o re-corre el deploy, o mergea el PR correcto. Con la cola por defecto de concurrency, solo sobrevive el pendiente más nuevo.
Argo CD no sincronizó: auto-sync apagado, la app OutOfSync o un sync que falló (sin retry configurado, no reintenta el mismo commit).
Compruébalo: argocd app get classifier (Sync Status y el último sync) · la UI de Argo CD
Arreglo: Sync manual, o arregla el error; con auto-sync, el próximo commit vuelve a disparar el sync.
Alguien cambió el cluster a mano (kubectl set image o edit) y git no lo sabe; sin selfHeal, queda así hasta el próximo sync.
Compruébalo: argocd app diff classifier (exit 1 si hay diferencias) · kubectl rollout history deploy/gateway -n classifier
Arreglo: El cambio va a git con un PR. Con selfHeal, Argo CD vuelve solo a lo que dice git.
Un tag que se mueve (:latest) o reconstruido: el manifest no cambia, así que no hay rollout, y cada nodo puede seguir con la imagen que ya tenía con ese nombre.
Compruébalo: kubectl get pods -n classifier -l app.kubernetes.io/name=gateway -o jsonpath='{..imageID}' (el digest que corre de verdad)
Arreglo: Tag = los 7 primeros caracteres del SHA, repos configurados con tags inmutables y, mejor todavía, el digest en el manifest (@sha256:…).
Los jobs para mi runner self-hosted se quedan en Waiting for a runner to pick up this job... (el runner aparece Offline, o Idle sin tomarlos).
Corre primero
systemctl --type=service | grep actions.runner
La máquina está apagada o el servicio del runner no corre (una g4dn que se apagó, un reinicio sin el servicio instalado).
Compruébalo: en la máquina: sudo ./svc.sh status · sudo journalctl -u actions.runner.ORG-REPO.NOMBRE.service -f
Arreglo: Instálalo como servicio (sudo ./svc.sh install y sudo ./svc.sh start) o, mejor, usa runners efímeros que se crean por job.
No llega a GitHub: sin salida HTTPS por el puerto 443, un proxy o un firewall.
Compruébalo: los logs _diag/Runner_*.log de la carpeta del runner · ./config.sh --check (con --url y --pat) prueba cada servicio: PASS o FAIL
Arreglo: Abre la salida por el 443 a github.com, api.github.com y *.actions.githubusercontent.com, más los otros dominios de la lista de GitHub (para bajar actions y subir logs, artifacts y caché).
El runner quedó viejo: con el auto-update apagado (--disableupdate), GitHub deja de mandarle jobs 30 días después de una versión nueva.
Compruébalo: _diag/Runner_*.log (la versión y An update is available)
Arreglo: Actualiza el runner o reactiva el auto-update; con efímeros, usa una imagen con el runner al día.
Está online, pero no coincide: el job pide labels que el runner no tiene, o el runner está en un grupo sin acceso a tu repo.
Compruébalo: Settings → Actions → Runners (las labels de cada runner) contra el runs-on del job
Arreglo: runs-on con exactamente las labels del runner ([self-hosted, linux, x64, gpu-t4]) y el repo agregado al runner group.
El PR no se puede mergear (por ejemplo, el PR del manifest que abrió CI): un check obligatorio dice Expected — Waiting for status to be reported.
Corre primero
gh pr checks NUMERO --required
El PR lo abrió un workflow con GITHUB_TOKEN (github-actions[bot]): desde junio de 2026, sus workflows esperan aprobación y, si nadie aprueba, el check nunca corre. Igual con el PR de un fork de alguien que contribuye por primera vez.
Compruébalo: quién abrió el PR · el botón Awaiting approval en el PR
Arreglo: Alguien con permiso de escritura (write) aprieta Approve workflows to run. Para los PRs de CI, un token de GitHub App: con él, los checks corren solos.
El workflow se saltó: un filtro paths o branches lo dejó afuera, o el commit traía [skip ci]. Un check saltado así queda pendiente para siempre.
Compruébalo: gh pr diff NUMERO --name-only (los archivos del PR) contra on.pull_request.paths del workflow
Arreglo: No pidas como obligatorio un workflow con paths: que corra siempre y salte el job con un if (un job saltado por if cuenta como Success). O empuja un commit nuevo sin [skip ci].
El nombre del check obligatorio no coincide con el job: lo renombraste, o es un job de matrix (tests (3.13)).
Compruébalo: gh pr checks NUMERO (los nombres reales) · Settings → Rules: el nombre exacto que se pide
Arreglo: El mismo nombre en la regla y en el job; cuidado al renombrar jobs.
Usas merge queue y el workflow no escucha merge_group.
Compruébalo: el bloque on: del workflow
Arreglo: Agrega merge_group: a los triggers.
Git dice HEAD detached at … o You are in 'detached HEAD' state.
Corre primero
git status
Hiciste checkout de un commit, un tag o una rama remota (git checkout 3cd7b74, git checkout v1.2, git checkout origin/main).
Compruébalo: git status (HEAD detached at …)
Arreglo: Si solo mirabas: git switch - o git switch main. Para trabajar ahí: git switch -c nueva-rama.
Un rebase o un bisect en curso te deja en detached HEAD a propósito.
Compruébalo: git status (interactive rebase in progress, o You are currently bisecting)
Arreglo: Termínalo (git rebase --continue, git bisect reset) o abórtalo (git rebase --abort).
Hiciste commits en detached HEAD y saliste (Warning: you are leaving 1 commit behind…).
Compruébalo: el aviso trae el SHA · git reflog
Arreglo: git branch rescate SHA
En CI es normal: en un pull_request, actions/checkout deja HEAD en el merge commit de prueba, no en una rama; git rev-parse --abbrev-ref HEAD responde HEAD.
Compruébalo: git status en un paso del job
Arreglo: En un PR, la rama está en github.head_ref (GITHUB_HEAD_REF); ahí GITHUB_REF_NAME vale N/merge, y solo en un push es la rama. Si un paso tiene que empujar a la rama del PR, haz el checkout con ref: ${{ github.head_ref }}.
Pega un workflow de GitHub Actions (un archivo de .github/workflows/). Revisa 18 reglas típicas en tu navegador: permisos, actions sin fijar, triggers peligrosos, secretos, timeouts y deploys sin freno. Cada hallazgo dice el job, el paso y la línea. No reemplaza la validación de GitHub, que corre al hacer push.
Lee el YAML con js-yaml (de cdnjs): después de la primera visita queda guardado y funciona sin conexión.