diff --git a/workloads/docs-portal/docs/devsecops/cosign.md b/workloads/docs-portal/docs/devsecops/cosign.md
index fa97d2d..15ae440 100644
--- a/workloads/docs-portal/docs/devsecops/cosign.md
+++ b/workloads/docs-portal/docs/devsecops/cosign.md
@@ -104,6 +104,74 @@ Si la imagen fue firmada por este pipeline, el comando termina con
firmó, porque la firmó otra llave, o porque la imagen fue modificada
después — termina con `exit 1` y un error explícito.
+## Limitaciones conocidas y mejoras futuras
+
+Dos limitaciones identificadas al validar el pipeline end-to-end,
+documentadas a propósito como TODO — ninguna de las dos se implementó
+todavía.
+
+### 1. Se firma por tag, no por digest
+
+Hoy el pipeline firma `ecommerce-frontend:v1.0.104` — un **tag**, no un
+**digest** (`sha256:...`). Un tag es una etiqueta mutable: nada impide
+que, después de firmada la imagen, alguien (o un bug en el propio
+pipeline) vuelva a subir contenido distinto bajo el mismo tag
+`v1.0.104`. La firma original seguiría "verificando" — porque Cosign,
+al verificar por tag, resuelve el tag al digest que tenga *en ese
+momento*, no al que tenía cuando se firmó. Si el tag fue reasignado,
+se está verificando una imagen distinta de la que realmente se firmó,
+sin que nada avise.
+
+Esto no es hipotético: el propio Cosign lo advierte en cada firma de
+este pipeline (visto en los logs reales de cada run):
+
+```text
+WARNING: Image reference gitea.cruzcloud.net/.../ecommerce-frontend:v1.0.102
+uses a tag, not a digest, to identify the image to sign.
+This can lead you to sign a different image than the intended one.
+```
+
+**TODO:** capturar el digest exacto que devuelve `docker push` (o
+`docker buildx build --metadata-file`) y firmar/verificar contra ese
+digest en vez del tag —
+`gitea.cruzcloud.net/devops/ecommerce-frontend@sha256:...` en vez de
+`:v1.0.104`. No implementado todavía; requiere ajustar el step de
+build para exponer el digest como output y pasarlo a los steps de
+firma y verificación.
+
+### 2. El transparency log (Rekor) está deshabilitado
+
+Ya se explicó arriba, en la sección "Por qué `--tlog-upload=false`" de
+esta misma página, la decisión consciente de no publicar en el
+transparency log para este registry privado. Vale la pena nombrar en
+simple qué es lo que se está dejando afuera:
+
+Un **transparency log** (Rekor, el de Sigstore) es un registro
+público, append-only, criptográficamente verificable, de "quién firmó
+qué imagen y cuándo" — pensalo como un libro contable público que
+nadie puede editar ni borrar después de escrito, solo agregar filas
+nuevas. Cualquiera puede consultar ese libro para confirmar de forma
+independiente (sin confiar en el propio proyecto, ni en la llave
+privada, ni en el registry) que una firma específica existió en un
+momento específico.
+
+Sin transparency log, la garantía que queda es más débil: *"esta firma
+la generó quien tuviera la llave privada en el momento en que se
+verificó"* — pero no hay ningún registro externo e inmutable que
+demuestre *cuándo* se generó, ni una forma de detectar si alguien con
+acceso a la llave privada firmó algo por fuera del pipeline sin que
+quede rastro. Para un lab personal con un registry privado, ese
+trade-off es razonable (ver la advertencia más abajo en esta misma
+página). Pero es una limitación real, no solo un detalle de
+configuración — importa especialmente el día que este mismo patrón se
+use en un contexto con más de una persona firmando, o con un registry
+que deje de ser privado.
+
+**TODO:** si en algún momento el registry deja de ser exclusivamente
+privado, o se suma más de una persona con acceso a la llave de firma,
+reevaluar habilitar el transparency log público de Sigstore (o correr
+uno privado propio) en vez de mantenerlo deshabilitado.
+
## Qué falta (a propósito, todavía)
Hoy la verificación de firma corre como smoke test **dentro del mismo
diff --git a/workloads/docs-portal/docs/devsecops/index.md b/workloads/docs-portal/docs/devsecops/index.md
index 23d5761..514aceb 100644
--- a/workloads/docs-portal/docs/devsecops/index.md
+++ b/workloads/docs-portal/docs/devsecops/index.md
@@ -1,9 +1,22 @@
# DevSecOps
Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea
-Actions, empezando por un solo repo de referencia — el frontend de ARI
-Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — antes
-de replicarlo a `commerce-backend` y `docs-portal`.
+Actions. Se empezó por un solo repo de referencia — el frontend de ARI
+Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — y ya
+está replicado, con evidencia real de corridas en verde, a los tres
+componentes del monorepo:
+
+| App | Workflow | Imagen | Adaptación de Semgrep |
+|---|---|---|---|
+| Frontend (Next.js) | `build.yaml` | `ecommerce-frontend` | `p/typescript` + `p/react` + `p/nextjs` + `p/security-audit` |
+| Backend (Medusa v2, API TypeScript) | `build-medusa.yaml` | `ecommerce-medusa` | `p/typescript` + `p/security-audit` + `p/owasp-top-ten` (sin React/Next.js — es una API, no SSR) |
+| Docs Portal (MkDocs, Python) | `deploy-docs.yaml` | `docs-portal` | `p/python` + `p/security-audit` (sin código de aplicación propio hoy) |
+
+Cada app tiene su propia llave pública de Cosign commiteada
+(`cosign.pub` en su propio directorio), pero las tres comparten el
+mismo par de llaves de firma (mismos secrets `COSIGN_PRIVATE_KEY` /
+`COSIGN_PASSWORD` a nivel de repo) — una sola identidad de firma para
+todo el registry de este lab.
Cinco herramientas, cada una respondiendo una pregunta distinta:
@@ -18,6 +31,11 @@ Cinco herramientas, cada una respondiendo una pregunta distinta:
## El pipeline completo
+El mismo patrón corre, con el mismo orden de etapas, en los tres
+workflows (`build.yaml`, `build-medusa.yaml`, `deploy-docs.yaml`) — lo
+que cambia entre ellos es el ruleset de Semgrep y qué manifiesto(s)
+escanea Trivy IaC (ver tabla arriba), no la estructura del pipeline.
+
```mermaid
flowchart TD
subgraph J1["job: gitleaks (push + pull_request)"]
@@ -28,8 +46,8 @@ flowchart TD
A2 -- "limpio" --> B0
subgraph J2["job: build (solo push a main, needs: gitleaks)"]
- B0[checkout + validaciones de código] --> B1["Trivy IaC
(frontend.yaml)"]
- B1 -.informa.-> B2["Semgrep SAST
(audit mode)"]
+ B0[checkout + validaciones de código] --> B1["Trivy IaC
(manifiesto K8s de la app)"]
+ B1 -.informa.-> B2["Semgrep SAST
(ruleset por stack, audit mode)"]
B2 -.informa.-> B3[login registry]
B3 --> B4["docker build
(push: false, load: true)"]
B4 --> B5["Trivy imagen
CRITICAL"]
@@ -40,7 +58,7 @@ flowchart TD
B8 --> B9["cosign sign"]
B9 --> B10["cosign verify
(smoke test)"]
B10 -- "firma inválida" --> XC["❌ detenido"]
- B10 -- "firma válida" --> B11["actualizar frontend.yaml
(GitOps, Argo CD sincroniza)"]
+ B10 -- "firma válida" --> B11["actualizar manifiesto de la app
(GitOps, Argo CD sincroniza)"]
end
```
@@ -87,5 +105,15 @@ flowchart TD
- Admission controller en el cluster que verifique la firma de Cosign
antes de dejar correr un Pod (ver [cosign.md](cosign.md)) — hoy la
verificación es solo un smoke test dentro del propio pipeline.
-- Replicar este mismo patrón a `commerce-backend` (Medusa) y
- `docs-portal`, adaptando lo que corresponda a cada stack.
+- Firmar por digest en vez de por tag, y reevaluar el transparency log
+ de Sigstore — ver
+ [limitaciones conocidas de Cosign](cosign.md#limitaciones-conocidas-y-mejoras-futuras).
+- `commerce-backend` tiene dos CVE CRITICAL documentados como excepción
+ puntual en `workloads/commerce-backend/.trivyignore` (Go stdlib
+ embebido en el binario de esbuild, sin fix compatible con la versión
+ de Vite que usa el admin-sdk de Medusa hoy) — revisar en cada bump de
+ `@medusajs/*` si ya deja de ser necesaria.
+- Contención de recursos entre Gitea y su runner bajo carga real —
+ causa raíz confirmada, fix propuesto y documentado, aplicación
+ manual pendiente (ver
+ [playbook de contención de recursos](../playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md)).
diff --git a/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md b/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md
index c9cd7d4..c0c7885 100644
--- a/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md
+++ b/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md
@@ -81,8 +81,7 @@ estable ~0.37–0.4s sostenida por 24+ minutos sin un solo 504.
servicios), **no relacionado al fix de protocolo del túnel** — no
se reabrió como parte de este incidente.
- **Pendiente:** vigilar si estas ventanas de degradación coinciden
- sistemáticamente con builds/deploys de Gitea Actions. Si es
- recurrente, considerar mover el runner (`gitea-runner`) a otro host
- o limitarle recursos para evitar que compita con el resto de las
- apps del NAS.
+ **Actualización 2026-08-15:** sí fue recurrente — ver
+ [contención de recursos entre Gitea y su runner](incidente-contencion-recursos-gitea-runner-2026-08.md)
+ para la causa raíz confirmada (ninguno de los dos contenedores
+ tiene límites de recursos reales) y la propuesta de fix.
diff --git a/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
new file mode 100644
index 0000000..447c467
--- /dev/null
+++ b/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
@@ -0,0 +1,133 @@
+# Incidente: contención de recursos entre Gitea y su runner (2026-08)
+
+| | |
+|---|---|
+| **Ventana del incidente** | 2026-08-15, durante el primer intento real de correr el pipeline de seguridad completo en `commerce-backend` |
+| **Servicio afectado** | `gitea` y `gitea-runner` (mismo host ZimaOS) |
+| **Impacto** | Un job de CI en curso (build pesado de Medusa + Trivy) fue cancelado a mitad de camino porque ambos contenedores se reiniciaron solos |
+
+## Contexto
+
+Este hallazgo estaba **anotado como pendiente** en el playbook del
+[504 del túnel](incidente-504-gitea-tunnel.md): una ventana de latencia
+alta y algunos 502/530 habían coincidido, en su momento, con una
+ejecución pesada de Gitea Actions — anotado como "contención de
+recursos local, no relacionado al fix del túnel, vigilar si es
+recurrente". Al triplicar la carga agregando el mismo pipeline de
+seguridad a `commerce-backend` y `docs-portal`, se volvió a ver en
+vivo, esta vez con evidencia suficiente para confirmar la causa.
+
+## Síntoma
+
+Un run de `build-medusa.yaml` (con Gitleaks ya en verde, build de
+Docker en curso) quedó cancelado a mitad de camino. El log del runner
+mostró, en la misma ventana de un par de minutos:
+
+```text
+level=error msg="failed to fetch task" error="unavailable: 502 Bad Gateway"
+level=info msg="runner: ... shutdown initiated, waiting 0s for running jobs to complete before shutting down"
+level=warning msg="runner: ... cancelled in progress jobs during shutdown"
+level=info msg="Starting runner daemon"
+```
+
+El mismo patrón se repitió una segunda vez pocos minutos después. En
+paralelo, `docker ps` mostró que el contenedor `gitea` también se
+había reiniciado casi al mismo tiempo.
+
+## Diagnóstico
+
+`docker inspect` sobre ambos contenedores en el momento del incidente
+mostró:
+
+- `ExitCode=0` y `RestartCount=0` en los dos — **no fue un crash ni un
+ OOM-kill** (eso dejaría un `ExitCode` distinto de 0 o
+ `OOMKilled=true`, y el conteo de reinicios de la política de Docker
+ subiría). El `StartedAt` cambió igual, lo que indica un reinicio
+ limpio disparado por algo externo al propio proceso — muy
+ probablemente el supervisor de apps de CasaOS (`zimaos-app-management`),
+ aunque no se pudo confirmar por logs (`/var/log/casaos/log.log` es
+ de acceso root-only, sin sudo sin password disponible en esta
+ sesión).
+- Memoria del host en el momento del incidente: **~1.3 GB libres de
+ 15 GB totales**, con ~2 GB de swap en uso — el host estaba bajo
+ presión real de memoria, no solo de CPU.
+- Ningún límite de recursos real en ninguno de los dos contenedores
+ (ver detalle abajo).
+
+## Causa raíz
+
+Ni `gitea` ni `gitea-runner` tienen aislamiento de recursos real
+frente al resto del host:
+
+| | `gitea` | `gitea-runner` |
+|---|---|---|
+| Límite de memoria | ~15.5 GB (≈ toda la RAM del host, no es un límite real) | **ninguno** |
+| Límite de CPU (cuota dura) | ninguno | ninguno |
+| Peso relativo de CPU (`cpu-shares`) | **90** (muy por debajo del default de Docker, 1024) | 0 → default Docker (**1024**) |
+
+Con `gitea` en 90 y `gitea-runner` en el default de 1024, bajo
+contención de CPU **el runner tiene ~11x más prioridad que el propio
+servidor Gitea** — lo opuesto a lo deseable, ya que Gitea es el
+servicio que necesita seguir respondiendo peticiones HTTP (incluidas
+las del propio runner haciendo polling) mientras un build pesado corre.
+
+Sumado a que `gitea-runner` no tiene techo de memoria: un job pesado
+(build de Docker de una imagen con `node_modules` grande + escaneo de
+Trivy con vuln + secret scanning) puede consumir memoria sin límite,
+empujando al host entero a swap y dejando lento/no-responsivo a todo
+lo demás — incluida la propia base de datos SQLite de Gitea (ver
+[incidente de SQLite en modo DELETE](incidente-sqlite-modo-delete-2026-08.md),
+que probablemente reaparezca con más frecuencia bajo este mismo tipo
+de presión, aunque WAL reduce el impacto).
+
+`gitea-runner` tampoco corre gestionado por el mismo `docker-compose.yml`
+de Gitea — se crea con un `docker run` suelto desde
+`scripts/deploy-lab.sh` (`create_gitea_runner_container()`), montando
+`/var/run/docker.sock` directo (sibling containers, ver
+[incidente de Docker-in-Docker](incidente-docker-in-docker-semgrep-2026-08.md)).
+Esto no es la causa del incidente de recursos, pero significa que
+cualquier ajuste de límites tiene que aplicarse en dos lugares
+distintos: el `docker-compose.yml` de `gitea` y el script que crea
+`gitea-runner`.
+
+!!! info "La concurrencia del runner ya estaba bien"
+ `capacity: 1` en el `config.yaml` del runner ya limita la
+ ejecución a **un solo job a la vez** — no es un problema de
+ "demasiados jobs en paralelo". El problema es que incluso un solo
+ job pesado, sin ningún límite de recursos, puede acaparar
+ suficiente CPU/memoria como para dejar sin aire al resto del host.
+
+## Fix propuesto (documentado, aplicación manual pendiente)
+
+1. **`scripts/deploy-lab.sh`** (rama `fix/gitea-runner-resource-limits`,
+ pendiente de mergear): agrega `--cpu-shares 512 --memory 8g
+ --memory-swap 10g` a la creación de `gitea-runner`. Solo afecta a la
+ próxima vez que se cree el contenedor (la función es idempotente),
+ no al que ya está corriendo.
+2. **Fix inmediato en caliente** (sin recrear contenedores), a aplicar
+ manualmente por fuera de este repo:
+
+ ```bash
+ docker update --cpu-shares 1024 gitea
+ docker update --cpu-shares 512 --memory 8g --memory-swap 10g gitea-runner
+ ```
+
+3. **`docker-compose.yml` de Gitea** (fuera de este repo, acceso
+ root-only): subir `cpu_shares` de `gitea` de 90 a 1024 — pendiente
+ de aplicar manualmente, no se implementó todavía.
+4. **Mover `gitea-runner` a k3d** como job/pod separado: evaluado como
+ posible mejora de fondo (aislamiento real vía Kubernetes en vez de
+ límites de Docker sueltos), pero queda **solo como recomendación
+ documentada** — no implementado en esta vuelta.
+
+## Validación
+
+Con `gitea` y `gitea-runner` estables (sin reinicios) después del
+incidente, se reintentó el mismo pipeline y corrió de punta a punta
+sin interrupciones — pero eso confirma que el host se estabilizó
+después del pico de carga, **no** que el fix de límites de recursos ya
+esté aplicado (sigue pendiente, ver arriba). La correlación completa
+carga-pesada → reinicio ya se había visto antes, en dos fechas
+distintas (2026-07-30 y 2026-08-11) además de esta — no es un evento
+aislado, es un patrón recurrente que ahora tiene una causa raíz
+concreta y una propuesta de fix.
diff --git a/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
new file mode 100644
index 0000000..a2aa6f8
--- /dev/null
+++ b/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
@@ -0,0 +1,93 @@
+# Incidente: el ingress del túnel rompió específicamente a Cosign, no al resto del pipeline (2026-08)
+
+| | |
+|---|---|
+| **Ventana del incidente** | 2026-08-15, primer intento de firmar una imagen con Cosign en `build.yaml` |
+| **Servicio afectado** | Cadena Cloudflare Tunnel → NPM (Nginx Proxy Manager) → Gitea, específicamente el endpoint del registry de contenedores |
+| **Impacto** | `cosign sign` fallaba siempre, en cada intento — el resto del pipeline (Gitleaks, Trivy, Semgrep, build, push de la imagen) funcionaba normal contra el mismo registry |
+
+## Síntoma
+
+Todos los pasos anteriores del pipeline pasaban, incluido `docker push`
+de la imagen al registry de Gitea. El paso de Cosign, inmediatamente
+después, fallaba siempre con:
+
+```text
+Error: signing [gitea.cruzcloud.net/***/ecommerce-frontend:v1.0.102]:
+accessing entity: invalid realm in www-authenticate: realm scheme
+"http" not allowed for a secure registry; use https
+```
+
+Lo llamativo: **`docker push` a ese mismo registry, un paso antes,
+funcionaba sin problema.** Si el registry estuviera mal configurado en
+general, se esperaría que fallara también el push, no solo la firma.
+
+## Diagnóstico
+
+El mensaje da la pista exacta: al autenticar contra el registry,
+Cosign recibe un header `WWW-Authenticate` cuyo campo `realm` apunta a
+una URL con esquema `http://` en vez de `https://`. Cosign rechaza
+explícitamente seguir un realm `http` contra lo que él considera "un
+registry seguro" (un dominio público, no `localhost`) — es una
+protección intencional del lado de Cosign, no un bug.
+
+`docker push`/`docker login` son más permisivos con esto (o cachean la
+sesión de otra forma) y no chocan con el mismo problema, por eso solo
+Cosign lo mostraba.
+
+## Causa raíz
+
+El realm que arma Gitea en su respuesta `WWW-Authenticate` depende de
+que Gitea sepa correctamente que la petición le llegó por HTTPS
+(típicamente vía el header `X-Forwarded-Proto` o el Server Name que ve
+en la conexión TLS que termina antes de llegar a él). La cadena real
+acá es **Cloudflare Tunnel → NPM → Gitea** — si el proxy host de NPM
+para `gitea.cruzcloud.net` no tiene el *Origin Server Name* (SNI hacia
+el origen) configurado correctamente, Gitea puede terminar creyendo
+que la conexión entrante fue por HTTP plano, y arma el realm con ese
+esquema equivocado.
+
+## Fix aplicado
+
+Corrección de la regla de ingress de `cloudflared`/NPM para
+`gitea.cruzcloud.net`: HTTPS hacia el origen con el *Origin Server
+Name* correcto. El fix fue enteramente de infraestructura — no hubo
+ningún cambio en este repo más allá de un commit vacío para volver a
+disparar el pipeline y confirmar el fix contra credenciales reales.
+
+## La misma causa raíz de fondo, dos síntomas distintos
+
+Este incidente **no es el mismo bug** que el
+[504 Gateway Timeout intermitente del túnel](incidente-504-gitea-tunnel.md)
+ya documentado — ese fue inestabilidad de QUIC/UDP en `cloudflared`,
+resuelto forzando `TUNNEL_TRANSPORT_PROTOCOL=http2`. Pero **ambos
+viven en la misma capa**: la cadena de ingress
+Cloudflare Tunnel → NPM → Gitea, mal configurada de dos formas
+distintas, descubiertas en momentos distintos:
+
+- Una regla de **transporte** (QUIC vs HTTP/2) causando 504s
+ intermitentes en *cualquier* petición HTTP a los dominios detrás del
+ túnel.
+- Una regla de **SNI/origin server name** causando que Gitea generara
+ un realm HTTP incorrecto, rompiendo específicamente a un cliente
+ (Cosign) que valida ese detalle de forma estricta.
+
+La lección compartida: en una cadena de proxies con TLS terminando en
+varios saltos, un solo campo de configuración mal puesto en cualquiera
+de los saltos puede manifestarse como fallas completamente distintas
+según qué tan estricto sea el cliente al otro lado — la mayoría de las
+herramientas HTTP normales ni lo notan, pero una herramienta de
+seguridad como Cosign, que valida explícitamente el esquema del
+realm, sí.
+
+## Validación
+
+Confirmado en una corrida real posterior al fix: `cosign sign` y
+`cosign verify` completaron sin error contra el registry real, con
+credenciales reales, de punta a punta.
+
+!!! note "Nota relacionada, no parte de este incidente"
+ El mismo log de este fallo mostró, además del error de realm, el
+ warning esperado de Cosign sobre firmar por tag en vez de por
+ digest — ver
+ [limitaciones conocidas de Cosign](../devsecops/cosign.md#limitaciones-conocidas-y-mejoras-futuras).
diff --git a/workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md
new file mode 100644
index 0000000..f67933b
--- /dev/null
+++ b/workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md
@@ -0,0 +1,102 @@
+# Incidente: Docker-in-Docker roto en el runner — por qué Semgrep corre nativo (2026-08)
+
+| | |
+|---|---|
+| **Ventana del incidente** | 2026-08-15, primer intento de correr Semgrep como container aparte en `build.yaml` |
+| **Servicio afectado** | Gitea Actions runner (`gitea-runner`, imagen `act_runner`) |
+| **Impacto** | Cualquier step que intentara `docker run -v "$(pwd):/algo"` desde dentro de un job fallaba con `read-only file system` o rutas inexistentes |
+
+## Docker-in-Docker vs. sibling containers, para quien recién arranca en CI/CD
+
+Cuando un job de CI necesita usar Docker (por ejemplo, para correr una
+herramienta empaquetada como imagen), hay dos formas distintas de
+dárselo, y confundirlas rompe cosas de maneras difíciles de
+diagnosticar:
+
+- **Docker-in-Docker (DinD):** el contenedor del job tiene su **propio**
+ daemon Docker corriendo adentro, completamente aislado del daemon
+ del host. Cuando el job hace `docker run`, ese contenedor nuevo nace
+ *dentro* del contenedor del job, como una muñeca rusa. El
+ filesystem que ve ese daemon interno es el del contenedor del job,
+ no el del host real.
+- **Sibling containers (contenedores hermanos):** el contenedor del
+ job **no** tiene su propio daemon — en cambio, monta el socket del
+ daemon Docker del **host** (`/var/run/docker.sock`) y habla
+ directamente con él. Cuando el job hace `docker run`, ese contenedor
+ nuevo nace como **hermano** del contenedor del job (mismo nivel,
+ mismo host, mismo daemon), no adentro suyo.
+
+La imagen `act_runner` que usa este runner de Gitea Actions usa el
+segundo modelo: **sibling containers**, montando el socket del daemon
+del host (ver `create_gitea_runner_container()` en
+`scripts/deploy-lab.sh`, que monta
+`-v /var/run/docker.sock:/var/run/docker.sock`).
+
+## Por qué eso rompe un `docker run -v` ingenuo
+
+Con sibling containers, cuando un step dentro de un job pide
+
+```bash
+docker run -v "${{ github.workspace }}:/src" alguna-imagen
+```
+
+ese comando lo ejecuta el **daemon del host**, no el contenedor del
+job. `${{ github.workspace }}` es una ruta que existe *dentro* del
+contenedor del job (por ejemplo `/workspace/devops/apps-registry`) —
+pero el daemon del host, al crear el contenedor hermano, intenta
+montar esa misma ruta **desde el filesystem del host real**, donde
+probablemente no existe (o existe otra cosa completamente distinta ahí).
+
+## Síntoma
+
+Al intentar correr Semgrep como container aparte (`docker run` desde
+dentro del step), el error observado en el run real fue:
+
+```text
+mkdir /workspace: read-only file system
+```
+
+No un "no existe la ruta" limpio — un intento de crear el directorio
+que falla porque, en el contexto real del daemon del host, esa ruta
+cae en un punto de montaje de solo lectura (o simplemente no es un
+lugar donde el daemon del host puede/debe escribir).
+
+## Causa raíz
+
+Sibling containers, no Docker-in-Docker: un `docker run -v
+"${{ github.workspace }}:/src"` ejecutado desde dentro de un job
+intenta montar, en el daemon del **host**, una ruta que solo tiene
+sentido **dentro** del contenedor del job. El daemon del host no ve
+esa ruta como el mismo directorio — la ve como una ruta arbitraria de
+su propio filesystem.
+
+## Fix aplicado
+
+Se evita el problema de raíz: **Semgrep corre nativo**, instalado
+directo en el contenedor del job (mismo criterio ya usado para
+Gitleaks, Trivy, Syft y Cosign — todos se instalan como binario/paquete
+dentro del job, ninguno corre como container aparte):
+
+```bash
+python3 -m venv /tmp/semgrep-venv
+/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
+```
+
+### Por qué un venv y no `pip3 install` directo
+
+La imagen del runner ya trae paquetes de Python instalados por `apt`
+(por ejemplo `PyJWT`) sin metadata compatible con `pip`. Un
+`pip3 install --break-system-packages` sobre esa base falla al
+intentar reemplazarlos (`RECORD file not found`) porque `pip` no
+encuentra el registro de archivos que necesita para saber qué está
+reemplazando. Un venv aislado evita tocar los paquetes del sistema por
+completo — Semgrep y sus dependencias quedan en su propio directorio,
+sin interferir con nada que `apt` ya haya instalado.
+
+## Validación
+
+Validado localmente contra `docker.gitea.com/runner-images:ubuntu-latest`
+(la misma imagen que usa el runner real) que `python3`/`pip3` están
+disponibles, y confirmado en una corrida real posterior al fix que
+Semgrep corre y reporta hallazgos sin ningún error de Docker de por
+medio.
diff --git a/workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md
new file mode 100644
index 0000000..4c67047
--- /dev/null
+++ b/workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md
@@ -0,0 +1,103 @@
+# Incidente: SQLite en modo DELETE causando contención en Gitea (2026-08)
+
+| | |
+|---|---|
+| **Ventana del incidente** | 2026-08-15, coincidiendo con el arranque del runner de Gitea Actions y el polling frecuente de tareas |
+| **Servicio afectado** | `gitea` (base de datos SQLite propia, `gitea.db`) |
+| **Impacto** | Errores intermitentes `database is locked (SQLITE_BUSY)` al escribir desde distintos componentes de Gitea (runner, web, cron) al mismo tiempo |
+
+## Contexto: por qué Gitea usa SQLite acá
+
+Este lab corre Gitea con `DB_TYPE = sqlite3` (no Postgres/MySQL) — una
+sola base de datos, un solo archivo (`gitea.db`), sin servidor de base
+de datos aparte. Es la opción correcta para un lab de un solo nodo,
+pero SQLite tiene una particularidad importante: por defecto abre el
+archivo en **modo DELETE** (el modo "clásico" de journaling).
+
+## Qué es el modo DELETE y por qué molesta acá
+
+En modo DELETE, cada transacción de escritura:
+
+1. Crea un archivo de journal temporal (`gitea.db-journal`).
+2. Toma un **lock exclusivo sobre el archivo completo** de la base de
+ datos mientras dura la escritura.
+3. Borra el journal al terminar.
+
+Ese lock exclusivo es el problema: **mientras una escritura está en
+curso, nada más puede leer ni escribir** — ni siquiera otro proceso
+que solo quiere leer una fila que no tiene nada que ver con la que se
+está escribiendo.
+
+!!! info "Por qué esto pega justo en Gitea Actions"
+ El runner de Gitea Actions hace *polling* constante contra la API
+ de Gitea (cada pocos segundos, según `fetch_interval` en su
+ `config.yaml`) para preguntar "¿hay una tarea nueva?", y además
+ escribe actualizaciones de estado de cada step casi en tiempo real
+ mientras un job corre. Sumale los cron jobs internos de Gitea y el
+ tráfico normal de la web UI. Con la base en modo DELETE, todo eso
+ compite por el mismo lock exclusivo — cuantas más cosas escriben
+ seguido, más chance de pisarse.
+
+## Síntoma
+
+`SQLITE_BUSY` intermitente en los logs de Gitea, típicamente en
+operaciones de corta duración (actualizar el estado de un step,
+registrar un heartbeat del runner) que no deberían tener motivo para
+fallar por contención:
+
+```text
+[E] can't update runner status: database is locked (5) (SQLITE_BUSY)
+```
+
+## Causa raíz
+
+Modo de journaling `DELETE` (el default de SQLite si no se configura
+nada distinto) combinado con el patrón de acceso de Gitea Actions:
+muchas escrituras cortas y frecuentes desde procesos distintos
+(runner, servidor web, cron), cada una compitiendo por un lock
+exclusivo de archivo completo.
+
+## Fix aplicado
+
+Se cambió el modo de journaling a **WAL** (Write-Ahead Logging) en
+`app.ini`:
+
+```ini
+[database]
+; journal_mode por defecto (DELETE) toma un lock exclusivo del archivo
+; completo en cada escritura. WAL permite lectores concurrentes mientras
+; hay una escritura en curso -- fix para "database is locked" bajo el
+; polling frecuente de Actions + cron jobs (diagnosticado 2026-08-15).
+SQLITE_JOURNAL_MODE = WAL
+```
+
+### Por qué WAL resuelve esto (y qué NO resuelve)
+
+En modo WAL, las escrituras no se aplican directo al archivo principal
+de la base: se van agregando a un archivo aparte (`gitea.db-wal`), y
+los lectores siguen leyendo del estado consistente más reciente sin
+bloquearse. La regla cambia de *"una escritura bloquea todo"* a
+*"una escritura bloquea solo a otra escritura"* — los lectores dejan
+de competir por el lock.
+
+!!! warning "WAL reduce la contención, no la elimina"
+ WAL sigue permitiendo **una sola escritura a la vez** — dos
+ escrituras concurrentes todavía pueden chocar y devolver
+ `SQLITE_BUSY` si el proceso no reintenta. En una corrida de este
+ mismo playbook se vio, después de aplicado el fix, una única
+ ocurrencia aislada de `database is locked` coincidiendo con un
+ reinicio del contenedor de `gitea` (ver
+ [contención de recursos de Gitea/runner](incidente-contencion-recursos-gitea-runner-2026-08.md)) —
+ consistente con una escritura en curso justo en el momento del
+ reinicio, no con que WAL no esté funcionando. Frecuencia bajó de
+ "molesta con cierta regularidad" a "un caso aislado en varias
+ horas de uso intensivo".
+
+## Validación
+
+El cambio se aplicó directo en el `app.ini` montado como volumen del
+contenedor de `gitea` (no en un `docker exec` puntual) — persiste
+across reinicios del contenedor, confirmado leyendo el archivo montado
+después de un reinicio real del contenedor (ver
+[contención de recursos](incidente-contencion-recursos-gitea-runner-2026-08.md)),
+no solo aplicado en caliente y perdido al reciclar el contenedor.
diff --git a/workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md
new file mode 100644
index 0000000..0209845
--- /dev/null
+++ b/workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md
@@ -0,0 +1,122 @@
+# Incidente: anchors YAML no soportados por Gitea Actions — y un diagnóstico equivocado en el camino (2026-08)
+
+| | |
+|---|---|
+| **Ventana del incidente** | 2026-08-15, desde el primer commit que agregó Gitleaks a `build.yaml` |
+| **Servicio afectado** | Gitea Actions — el workflow `build.yaml` (frontend) |
+| **Impacto** | `build.yaml` no generaba **ningún** `action_run`, ni en `push` ni en `pull_request` — el pipeline entero era invisible para Gitea, sin ningún error visible en la UI |
+
+## Qué es un anchor/alias YAML, para quien no lo conoce
+
+YAML tiene una forma de evitar repetir el mismo bloque dos veces:
+
+```yaml
+paths: &frontend_paths
+ - 'workloads/ecommerce/**'
+ - '.gitea/workflows/build.yaml'
+
+on:
+ push:
+ paths: *frontend_paths
+ pull_request:
+ paths: *frontend_paths
+```
+
+`&frontend_paths` es un **anchor** (marca ese nodo con un nombre).
+`*frontend_paths` es un **alias** (dice "poné acá una copia de lo que
+marcó ese anchor"). Es YAML 100% estándar — cualquier parser que siga
+la especificación lo resuelve sin problema, y es una forma común de no
+duplicar listas idénticas en el mismo archivo.
+
+## Síntoma
+
+`build.yaml` (con Gitleaks recién agregado, usando anchors para no
+repetir la lista de `paths` entre `push:` y `pull_request:`) no
+generaba ningún run en Gitea Actions. Ni en el push directo a la rama
+del PR, ni al abrir el pull request. Sin mensaje de error visible en
+la UI de Gitea — el workflow simplemente no aparecía en la lista de
+Actions, como si no existiera.
+
+## Primer diagnóstico (equivocado)
+
+La primera sospecha fue una indentación rota específicamente en el
+step de Semgrep: el bloque `python3 -c "..."` embebido dentro de un
+`run: |` tenía código pegado a columna 0, por debajo de la
+indentación esperada del block scalar de YAML. Eso *sí* era un
+problema real — cortaba el block scalar ahí mismo y en teoría podía
+hacer que Gitea rechazara el archivo con un error de parseo genérico
+(`could not find expected ':'`).
+
+Se corrigió la indentación, se validó con un parser YAML real y
+`bash -n` sobre los 20 steps del archivo, se mergeó a `main` como fix
+(`fix(ci): corregir indentación YAML rota en el step de Semgrep`, PR
+#19)... y `build.yaml` **siguió sin generar runs**.
+
+!!! warning "Por qué vale la pena contar el diagnóstico que no era"
+ El primer fix no estaba mal — la indentación efectivamente estaba
+ rota y corregirla era necesario. El error fue asumir que esa era
+ *la única* causa sin confirmarlo con una corrida real después del
+ fix. La lección de proceso: un fix que "tiene sentido" y que pasa
+ la validación local (parser YAML, `bash -n`) todavía necesita una
+ confirmación en vivo contra el sistema real antes de darlo por
+ cerrado — sobre todo cuando el síntoma es "no pasa nada" en vez de
+ un error explícito, que es exactamente el tipo de fallo más fácil
+ de dar por resuelto sin verificar.
+
+## Causa raíz real
+
+`deploy-docs.yaml` (sin anchors) corría normal. `build.yaml` (con
+anchors, agregados junto con Gitleaks) no generaba ningún run — ni
+en `push` ni en `pull_request`, desde el primer commit que los
+introdujo. El log del propio Gitea lo confirmó:
+
+```text
+unknown on type: &yaml.Node{...Value:"frontend_paths"...}
+```
+
+El parser propio de Gitea Actions para el bloque `on:` de un workflow
+**no resuelve `&anchor`/`*alias` antes de inspeccionar el tipo de
+nodo** — encuentra un anchor donde esperaba un valor ya resuelto, no
+sabe qué tipo de dato es, y descarta el archivo completo en
+silencio. `pyyaml` y cualquier parser YAML estándar sí resuelven el
+alias primero (por eso el archivo "parseaba bien" en cualquier
+herramienta de validación genérica) — es una limitación específica del
+parser de workflows de Gitea Actions, no un YAML inválido.
+
+Esto explica por qué el síntoma no tenía ningún error visible: no es
+que el job fallara, es que **Gitea nunca llegaba a registrar el
+workflow como existente**.
+
+## Fix aplicado
+
+Se eliminaron los anchors/alias. Las dos listas de `paths` (una para
+`push:`, otra para `pull_request:`) quedan **duplicadas
+literalmente**, sin referencia compartida:
+
+```yaml
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'workloads/ecommerce/**'
+ - '.gitea/workflows/build.yaml'
+ pull_request:
+ branches: [main]
+ paths:
+ - 'workloads/ecommerce/**'
+ - '.gitea/workflows/build.yaml'
+```
+
+Es más repetitivo, pero es el precio de que el parser de Gitea
+Actions lo entienda. Este mismo criterio (sin anchors, paths
+duplicados) se replicó a propósito en `build-medusa.yaml` y
+`deploy-docs.yaml` al agregarles el resto del pipeline de seguridad,
+para no repetir el mismo problema.
+
+## Validación
+
+Confirmado en vivo: tras mergear el fix, `build.yaml` generó su
+primer `action_run` real. Validado también con un parser YAML +
+`bash -n` sobre los 20 steps del archivo, igual que en el intento
+anterior — la diferencia esta vez fue confirmarlo contra una corrida
+real antes de cerrar el incidente.
diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml
index dbcc013..640f5b0 100644
--- a/workloads/docs-portal/mkdocs.yml
+++ b/workloads/docs-portal/mkdocs.yml
@@ -93,6 +93,11 @@ nav:
- 504 túnel Gitea: playbooks/incidente-504-gitea-tunnel.md
- Precios Medusa: playbooks/incidente-precios-medusa.md
- Imágenes NPM: playbooks/incidente-imagenes-npm.md
+ - SQLite modo DELETE: playbooks/incidente-sqlite-modo-delete-2026-08.md
+ - Anchors YAML en Gitea Actions: playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md
+ - Docker-in-Docker y Semgrep: playbooks/incidente-docker-in-docker-semgrep-2026-08.md
+ - Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
+ - Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md
- Guía del estudiante: