CI propio con GitHub Actions: el pipeline que publica
Montar un despliegue de Workers desde GitHub Actions con criterio: crear un token de API acotado, invocar `wrangler deploy` desde el workflow, cachear dependencias sin abrir agujeros, y separar los previews de los pull requests de la publicación en producción tras una aprobación explícita.
En cuanto un proyecto necesita que las pruebas decidan si algo se publica, que varios repositorios se coordinen o que una persona apruebe explícitamente el paso a producción, el CI propio recupera su sitio. GitHub Actions es la opción más común y el patrón es engañosamente simple: un workflow que instala dependencias y ejecuta wrangler deploy. Lo que separa ese ejemplo de tres líneas de un pipeline que puedes dejar corriendo sin vigilancia es todo lo demás: un token cuyo alcance no exceda lo necesario, una caché que acelere sin convertirse en un vector de entrada, y unas puertas que garanticen que ninguna rama sin revisar acabe atendiendo clientes. Esta lección trata precisamente de ese resto, porque el paso de despliegue es la parte fácil y también la única que casi todos los tutoriales cubren.
- Crear un token de API de Cloudflare con el alcance mínimo y guardarlo con seguridad.
- Escribir un workflow que despliegue con Wrangler y falle de forma útil cuando algo va mal.
- Cachear dependencias correctamente y reconocer cuándo una caché deja de ser segura.
- Separar el preview de un pull request del despliegue a producción con aprobación explícita.
El token es el permiso más peligroso del repositorio
Un token capaz de desplegar Workers puede sustituir el código que atiende a tus usuarios. Guardado como secreto de repositorio, queda al alcance de cualquier workflow que corra en él, y por tanto de cualquiera que pueda modificar un workflow. Esa cadena de razonamiento es la que debe gobernar cómo lo creas.
Merece la pena hacer el ejercicio completo antes de crear nada: enumera qué necesita tocar el despliegue —el Worker, quizá una zona para las rutas, quizá un namespace de KV si el pipeline lo administra— y no concedas ni un permiso más. Cada permiso extra es una capacidad que un atacante hereda entera el día que el secreto salga del repositorio.
Se genera en el panel de Cloudflare, y la plantilla de edición de Workers es un punto de partida razonable siempre que después acotes los recursos: la cuenta concreta, y las zonas concretas si el Worker usa rutas sobre un dominio. Un token que abarca todas las cuentas de la organización porque era más rápido de crear es una decisión que se paga entera el día que se filtre. Junto a él necesitarás el identificador de cuenta, que no es secreto pero conviene guardar igualmente como variable para no incrustarlo.
A diferencia de otros proveedores, los tokens de API de Cloudflare no se obtienen mediante identidad federada desde el runner, así que lo que guardas en GitHub es una credencial persistente. Como no puedes hacerla efímera, tienes que compensar por otras vías: alcance mínimo, rotación con calendario, un token distinto por entorno, y —lo más eficaz— restringir el token de producción a un GitHub Environment protegido, de modo que ningún workflow arbitrario pueda leerlo. Añadir una lista de direcciones IP permitidas es posible pero poco práctico con runners alojados; tiene sentido con runners propios.
Hay dos hábitos más que cuestan poco y evitan mucho. El primero es fijar permissions explícitamente en el workflow, porque el token propio de GitHub también se inyecta y por defecto puede tener más alcance del necesario. El segundo es anclar las acciones de terceros a un commit concreto en lugar de a una etiqueta móvil: una etiqueta puede reapuntarse, y una acción comprometida corre en el mismo job que tu secreto de despliegue.
Un token por entorno no es paranoia, es lo que hace que la separación que declaraste en el manifiesto sea real también en quién puede publicar. Si el mismo token despliega staging y producción, la única barrera entre ambos es el flag --env que alguien escriba en un archivo YAML, y los flags se editan en un pull request. Con tokens distintos, custodiados en environments distintos, un cambio en el workflow de staging no puede alcanzar producción ni por error ni a propósito.
El workflow que despliega de verdad
name: desplegar
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
publicar:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test
- run: npx wrangler deploy --env production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
Tres detalles merecen atención porque no son decorativos. El bloque concurrency con un grupo fijo impide que dos merges casi simultáneos publiquen a la vez y que gane el que termine último, que no tiene por qué ser el más reciente; con cancel-in-progress en falso, además, ningún despliegue se interrumpe a medias. La clave environment ata el job a un GitHub Environment, que es donde vivirá la aprobación manual y el secreto de producción. Y npm ci en lugar de npm install garantiza que se instala exactamente lo que fija el archivo de bloqueo, sin resoluciones nuevas: un pipeline que puede instalar una dependencia distinta a la que probaste no es reproducible.
Invocar Wrangler directamente con npx es perfectamente válido y tiene la virtud de que la versión que corre es la de tus dependencias. La acción oficial cloudflare/wrangler-action es la alternativa cómoda cuando quieres pasar secretos de runtime en el mismo paso o encadenar comandos, y hace por debajo lo mismo. Elige una y sé consistente; mezclar ambas en el mismo repositorio genera diferencias de versión difíciles de rastrear.
Falla pronto y falla barato. Antes del despliegue conviene ejecutar la comprobación de tipos y un wrangler deploy --dry-run, que empaqueta el Worker y valida la configuración sin publicar nada. Ese paso atrapa la clase entera de errores que de otro modo se descubren a mitad del despliegue —un binding mal escrito, un import que no resuelve en el runtime de Workers, un bundle que excede el tamaño permitido— y los convierte en un fallo del pipeline antes de tocar producción. Cuesta segundos y es la diferencia entre un pull request rojo y un despliegue a medias.
Cachear sin envenenar la caché
La instalación de dependencias domina la duración del pipeline y es lo primero que se cachea. La forma correcta es dejar que actions/setup-node lo haga por ti, porque calcula la clave a partir del hash del archivo de bloqueo y cachea el almacén del gestor de paquetes, no la carpeta de módulos instalados.
| Qué cachear | Recomendación |
|---|---|
| Almacén del gestor de paquetes | Sí, con la clave derivada del archivo de bloqueo. Es el caso estándar y seguro |
| Carpeta de módulos instalados | No; depende del sistema operativo y de binarios compilados, y suele restaurarse en un estado inconsistente |
| Artefactos de build | Solo si la clave incluye el hash de todas las entradas. Si no, servirás código viejo sin enterarte |
| Cualquier cosa escrita por un workflow de un fork | Nunca. Es la vía directa a un envenenamiento de caché |
Conviene también recordar que la caché no es un almacén infinito ni permanente: las entradas caducan por desuso y el repositorio tiene un tamaño máximo, a partir del cual las más antiguas se expulsan. Una clave demasiado específica —que cambie con cada commit— llena ese espacio con entradas que nadie volverá a leer y acaba expulsando justo la que sí servía. La combinación correcta es una clave exacta derivada del archivo de bloqueo y una clave de respaldo más genérica para el caso en que ese archivo haya cambiado.
El último punto es el que se ignora. Las cachés de GitHub Actions se comparten por ramas siguiendo una jerarquía: una entrada creada en la rama principal es legible desde cualquier rama, y —lo relevante— una entrada creada en una rama cualquiera puede acabar siendo restaurada por otro job. Si tu workflow de pull request escribe en la caché con una clave que después restaura el workflow de producción, has creado un canal por el que un cambio no revisado puede inyectar contenido en el pipeline que publica. La regla es simple: los jobs que despliegan restauran cachés, no las escriben, o usan un espacio de claves separado del que tocan las ramas de trabajo.
flowchart TD PR[pull request] --> T[tests y lint] T --> UP[versions upload] UP --> URL[url de vista previa en el comentario] MG[merge a main] --> T2[tests] T2 --> AP[aprobacion en environment production] AP --> DP[wrangler deploy] DP --> PROD[trafico real] style URL fill:#89b4fa,color:#11111b style AP fill:#fab387,color:#11111b style PROD fill:#f38ba8,color:#11111b
Puertas: preview en el pull request, producción tras aprobación
El pipeline maduro no es uno sino dos, con permisos distintos. El de pull request valida y produce una vista previa; el de la rama principal publica. La asimetría se implementa con los comandos que ya conoces: wrangler versions upload en el primero, wrangler deploy en el segundo.
# En el workflow de pull request
- run: npx wrangler versions upload --env staging --tag pr-${{ github.event.number }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_STAGING_TOKEN }}
Etiquetar la versión con el número del pull request tiene una virtud concreta: la etiqueta viaja con el artefacto y aparece en wrangler versions list, de modo que cualquiera puede relacionar una versión con la discusión que la originó sin salir de la terminal. Publicar además la URL de vista previa como comentario cierra el círculo, porque quien revisa el código no tiene que buscar dónde probarlo.
Fíjate en que el token es otro. El workflow que corre sobre código no revisado nunca debería tener acceso a la credencial de producción, y la forma de garantizarlo no es la buena voluntad sino los GitHub Environments: el secreto de producción se declara en el environment production, y solo los jobs que lo referencian pueden leerlo. Si además ese environment exige revisores, el despliegue se detiene esperando una aprobación humana que queda registrada.
Los pull requests desde forks merecen una nota. Por defecto no reciben secretos, lo cual es exactamente lo que quieres, y significa que su workflow no podrá subir una versión. Resistir la tentación de arreglarlo con el disparador que sí expone secretos al código del fork es una de las decisiones de seguridad más importantes que tomarás en el repositorio: ese atajo entrega la ejecución de código con tus credenciales a cualquiera que abra un pull request.
Alcance del token
Una cuenta, los permisos justos de Workers y las zonas concretas si usas rutas. Rotación con fecha en el calendario.
Environments
El sitio donde vive el secreto de producción y la aprobación humana. Ningún job que no los referencie puede leerlos.
Acciones ancladas
Referencia por commit, no por etiqueta. Lo que corre en tu runner con tus secretos no debe poder cambiar sin un commit tuyo.
Concurrencia
Un grupo por entorno, sin cancelación a medias. Evita que el orden de llegada decida qué versión se queda publicada.
Queda por decidir quién dispara producción. Publicar en cada merge a la rama principal es el flujo más sano cuando las pruebas son de fiar, porque mantiene pequeño el lote de cambios. Publicar desde una etiqueta o una release da control explícito sobre el momento y encaja mejor cuando hay coordinación con otros equipos. Lo que no funciona es el término medio ambiguo —un despliegue manual que a veces se hace y a veces no—, porque produce el peor estado posible: nadie sabe con certeza si lo que hay en la rama principal es lo que están ejecutando los usuarios.
Cuesta interiorizar que el archivo YAML del workflow tiene, en la práctica, los mismos privilegios que la persona con más permisos del proyecto. Puede ejecutar comandos arbitrarios, leer los secretos que le concedas y publicar en producción, y sin embargo suele revisarse con una fracción del cuidado que se dedica al código de la aplicación. Esa asimetría es la raíz de casi todos los incidentes de cadena de suministro de los últimos años, y el patrón se repite: nadie ataca tu Worker, atacan la acción de terceros que ejecutas sin fijar la versión, o la dependencia de desarrollo que se instala durante el build, o la caché que un job menos protegido escribió antes. El cambio de mentalidad que hay que hacer es dejar de ver el pipeline como fontanería y empezar a verlo como el componente de mayor privilegio del sistema. De ahí se deducen solas todas las prácticas: revisar los cambios del workflow con el mismo rigor que un cambio en el código de pago, anclar las acciones a un commit, dar a cada job el conjunto mínimo de secretos y no uno más, no permitir que código no revisado corra en un contexto con credenciales, y aceptar que el paso final a producción tenga una fricción deliberada. Y hay un corolario que casi nadie aplica y que vale su peso en oro: pregúntate qué pasaría si un atacante tuviera control total del runner durante un minuto. Si la respuesta es que podría desplegar en producción, entonces la seguridad de tu producción es exactamente la seguridad de tu CI, y eso hay que decidirlo a propósito, no descubrirlo.
- Crea un token de API acotado a una sola cuenta y a los permisos de edición de Workers, y guárdalo como secreto dentro de un GitHub Environment protegido.
- Escribe el workflow de la rama principal con
permissionsexplícitos,concurrencyy ejecución de pruebas antes del despliegue. - Añade un workflow de pull request que suba una versión con
wrangler versions uploadusando un token distinto y publique la URL de vista previa como comentario. - Configura la caché de dependencias y comprueba en dos ejecuciones seguidas que el tiempo de instalación baja y que la clave depende del archivo de bloqueo.
- Haz el ejercicio adversario: enumera cada paso del pipeline que podría ejecutar código de terceros y, para cada uno, di qué secreto tendría a su alcance. Corrige el primero que te incomode.