Marcos Ramírez BETA
Un reloj de cron averiado junto al logo de GitHub Actions y un engranaje de Cloudflare Workers

El cron de GitHub Actions falla: lo arreglé con Cloudflare

· ⏱ 11+ min lectura

Programé un post para que saliera a las 8:30 de la mañana. A las 9, ni rastro de él en la web.

Y no era la primera vez que lo notaba, pero esta vez decidí ir hasta el fondo del asunto en lugar de darle a un botón y mirar para otro lado. Spoiler: la culpa no era mía. La culpa era del reloj de GitHub Actions, que lleva meses haciendo lo que le da la gana con las tareas programadas.

Este es el post de cómo lo diagnostiqué y, sobre todo, cómo lo dejé arreglado para que no me vuelva a pasar.

Cómo se publica este blog (y por qué la hora importa)

Antes de nada, contexto, que si no esto no se entiende.

Este blog está hecho con Astro y se despliega como un sitio estático en Cloudflare Workers. Cada post tiene una fecha en el frontmatter, y el listado de la web filtra con una línea tan tonta como esta:

const posts = await getCollection('posts', p => p.data.date <= new Date());

Traducido: muestra solo los posts cuya fecha ya ha pasado. Programación de publicaciones del pobre, pero funciona.

El detalle que mucha gente pasa por alto es cuándo se evalúa ese new Date(). En un sitio estático no se evalúa cuando el lector entra en la web. Se evalúa en el build, cuando se genera el HTML. Es decir: un post con fecha 8:30 no aparece solo porque den las 8:30. Aparece cuando el sitio se reconstruye después de las 8:30.

¿Y quién reconstruye el sitio a intervalos regulares para que los posts programados salgan solos? Un cron. Y ahí estaba el problema.

El diagnóstico: el último deploy fue antes de la hora

Lo primero fue descartar lo obvio. ¿Estaba el post en master? Sí. ¿La fecha estaba bien, con su +0200 y todo? Sí: 2026-06-12 08:30:00 +0200, que en UTC son las 06:30. Hasta aquí, nada raro.

El segundo paso fue mirar el historial de despliegues. Y ahí saltó la liebre:

gh run list --workflow=astro.yml --limit 12

El último build automático había sido a las 05:42 UTC. O sea, casi una hora antes de la hora de publicación del post. Ese build miró el new Date(), vio que la fecha del post todavía era futura, y lo dejó fuera. Correcto por su parte.

El problema es lo que vino después: nada. No hubo otro build. El cron estaba configurado para correr cada 15 minutos, pero entre una ejecución y otra había huecos de una y dos horas. Mirando la lista hacia atrás daba escalofríos: 05:42, antes 03:50, antes 02:26, antes 01:27. Eso no es “cada 15 minutos” ni de lejos.

El workflow lo tenía clarísimo:

on:
  schedule:
    - cron: '*/15 * * * *'

Pedía ejecutarse cuatro veces por hora. GitHub le hacía caso una vez cada dos. La hora a la que el post tenía que salir cayó en uno de esos huecos. Resultado: post fantasma.

Por qué el cron de GitHub Actions no es de fiar

Aquí viene la parte importante, porque esto no es un bug puntual de mi repo. Es comportamiento conocido y, encima, va a peor.

La propia documentación de GitHub lo dice con todas las letras: los eventos schedule pueden retrasarse durante periodos de mucha carga, y si la carga es suficientemente alta, directamente se descartan. Y los picos de carga son justo al principio de cada hora, cuando medio planeta tiene crons programados a las en punto.

¿Y a qué horas pide ejecutarse un */15? A las :00, :15, :30 y :45. Las cuatro pegadas a los bordes de minuto donde todo el mundo amontona sus tareas. Estaba pidiendo turno en la cola más larga posible, cuatro veces por hora.

Y no es solo la documentación cubriéndose las espaldas. La comunidad lleva desde principios de 2026 reportando que las tareas programadas se retrasan cada vez más, con un incidente a nivel de plataforma alrededor del 23 de enero que GitHub acabó reconociendo y revirtiendo. Si tu cron necesita precisión, GitHub Actions no es el sitio.

No es paranoia: GitHub lleva un año regular

Lo del cron no es un caso aislado. Es un síntoma de algo más grande, y conviene ponerlo en contexto porque cambia la decisión de qué hacer.

Si miras el último año de la plataforma, GitHub Actions ha sido el servicio con más incidencias graves de toda la casa, con enero y febrero de 2026 como los peores meses. Y la racha ha seguido en primavera:

  • 📅 15 de mayo de 2026: degradación de Actions durante 65 minutos, con picos de hasta el 42% de ejecuciones fallidas.
  • 📅 26 de mayo de 2026: el sistema automático de revisión de cuentas suspendió por error la cuenta de servicio que usa Actions para autenticar los workflows. Durante horas, a la gente le saltaba que su cuenta estaba suspendida cuando no lo estaba.
  • 📅 28 de mayo de 2026: un cambio a medio desplegar en el servicio de autenticación tiró un 10% de las ejecuciones.

Tres incidencias serias en dos semanas. No te montes la lógica de publicación de tu blog encima de algo que se cae así de seguido. La conclusión práctica es simple: el reloj que decide cuándo sale mi contenido no puede vivir dentro de GitHub.

Las opciones sobre la mesa

Con el diagnóstico claro, había tres caminos. Los pongo todos porque la decisión tiene matices, no es “haz esto y ya”.

Opción 1: mover el reloj fuera de GitHub. Un cron externo y fiable que, cada 15 minutos, llame a la API de GitHub para disparar el despliegue (un workflow_dispatch). GitHub deja de decidir cuándo; solo ejecuta cuando se lo piden. El build sigue donde está, intacto.

Opción 2: filtrar por fecha en tiempo real, en el edge. En vez de filtrar en el build, servir el sitio con renderizado en el Worker y comprobar la fecha en cada petición. Así el post sale solo a su hora exacta sin necesidad de reconstruir nada. Elegante sobre el papel, pero implica cambiar la arquitectura de estático a renderizado en servidor. Mucho cambio para un problema de reloj.

Opción 3: disparar a mano los días de publicación. Cero código. Cero fiabilidad también: depende de que yo me acuerde a las 8:30 de la mañana. Descartada por motivos evidentes.

La 1 era la única que arreglaba la causa real sin reescribir medio sitio. Adelante con ella.

La solución: que Cloudflare mueva el reloj

La idea clave: el sitio ya vive en Cloudflare Workers. Y los Cron Triggers de Cloudflare sí cumplen el horario, porque corren en su propia infraestructura y no compiten con la cola global de medio GitHub.

Así que el plan fue: el mismo Worker que sirve el blog dispara, cada 15 minutos, una reconstrucción en GitHub Actions vía API. GitHub deja de programar nada; solo obedece cuando Cloudflare le da el toque.

Primero, el trigger en el wrangler.jsonc:

"triggers": {
  "crons": ["*/15 * * * *"]
}

Y luego, un handler scheduled en el Worker que hace la llamada a la API de GitHub (workflow_dispatch):

async function triggerRebuild(env) {
  if (!env.GH_DISPATCH_TOKEN) return;
  await fetch(
    'https://api.github.com/repos/USUARIO/REPO/actions/workflows/astro.yml/dispatches',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${env.GH_DISPATCH_TOKEN}`,
        'Accept': 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'User-Agent': 'mi-cron',
      },
      body: JSON.stringify({ ref: 'master' }),
    },
  );
}

export default {
  async scheduled(event, env, ctx) {
    ctx.waitUntil(triggerRebuild(env));
  },
  // ...el resto del Worker (fetch) sigue igual
};

El token, sin dejarlo tirado en el repo

Falta la llave: GH_DISPATCH_TOKEN. Es lo que autentica al Worker cuando llama a la API de GitHub. Sin él, GitHub responde 401 y no dispara nada.

Lo creas en GitHub como Personal Access Token de tipo fine-grained, con un único permiso y acotado a un solo repo. Cuanto menos pueda hacer ese token, mejor. Los pasos:

  • Vas a Settings → Developer settings → Fine-grained tokens → Generate new token.
  • Repository access: Only select repositories y eliges solo el repo del blog. Nada de “todos”.
  • Permissions → Repository → Actions: Read and write. Y para aquí. No le des nada más.
  • Le pones caducidad (un año va bien) y lo generas. Copias el valor github_pat_..., que solo se ve una vez.

Y ahora el detalle que parece obvio pero que mucha gente se salta: ese token NO va en un archivo del repositorio. Si tu repo es público (este lo es), commitear un token es regalárselo al primer bot que pasa, y los hay rastreando secrets a todas horas. Va como secret del Worker, cifrado en Cloudflare, fuera del código:

npx wrangler secret put GH_DISPATCH_TOKEN

Te pide pegar el valor por teclado y lo guarda cifrado. El Worker lo lee desde env.GH_DISPATCH_TOKEN, pero el valor nunca toca el repositorio. La diferencia entre hacer esto bien y mal es, literalmente, que te roben la cuenta o no.

Y el último paso, el que más me gusta: borrar el schedule del workflow de GitHub. Ya no pinta nada. El workflow_dispatch se queda (es la puerta por la que entra Cloudflare), pero el cron poco fiable se va. Una pieza menos que puede fallar.

Este tipo de marrones, mover el reloj de sitio para que un proceso salga a su hora de verdad, es justo lo que monto para empresas que tienen automatizaciones críticas colgando de un cron que un día decide no ejecutarse.

Lo que aprendí de paso

Tres cosas que me llevo de este lío, por si te ahorran la tarde:

  • En un sitio estático, la fecha de publicación se evalúa en el build, no en la visita. Si programas contenido, necesitas un reloj externo que reconstruya. No basta con poner la fecha y rezar.
  • El schedule de GitHub Actions es “best effort”, no una garantía. Para tareas donde la hora importa de verdad, sácalo de ahí.
  • Si ya estás en una plataforma con cron fiable, úsala. Tenía Cloudflare delante todo el tiempo. La mejor pieza nueva muchas veces es la que ya tenías instalada.

Mientras escribía esto, el deploy manual que lancé ya había puesto el post fantasma en su sitio. Pero el arreglo de verdad no era ese botón. Era quitarle a GitHub la responsabilidad de mirar el reloj.

Preguntas frecuentes

¿Por qué se retrasan las tareas programadas de GitHub Actions?

Porque el evento schedule es de tipo “best effort”: GitHub lo ejecuta cuando puede, no cuando toca. Su documentación avisa de que durante picos de carga (típicamente al inicio de cada hora) las tareas se retrasan e incluso se descartan. Un cron */15 pide turno justo en esos bordes, así que es de los más castigados.

¿Sirve un Cron Trigger de Cloudflare para disparar GitHub Actions?

Sí. Un Worker con un Cron Trigger puede llamar a la API de GitHub (workflow_dispatch) en cada ejecución para lanzar el workflow. Cloudflare corre el cron en su propia infraestructura, de forma mucho más puntual que el scheduler de GitHub, y tú dejas el build donde estaba.

¿Necesito un token para llamar a la API de GitHub desde el Worker?

Sí, un Personal Access Token con permiso de escritura sobre Actions. Se guarda como secret del Worker con wrangler secret put, nunca en el repositorio. El handler scheduled lo lee desde env y lo manda en la cabecera Authorization.

¿Por qué un post programado no aparece aunque ya sea su hora?

Porque en un sitio estático el filtro de fecha se evalúa en el build, no en cada visita. El post solo aparece tras una reconstrucción posterior a su hora de publicación. Si el cron que reconstruye no se ejecuta a tiempo, el post se queda esperando.

Fuentes


Compártelo si te ha resultado útil.

¿Tú dónde tienes el reloj de tus automatizaciones, dentro de GitHub o fuera? Cuéntame.

Y… la próxima vez que algo no salga a su hora, mira quién mueve el reloj.

Artículos relacionados

Un reloj marcando los 15 minutos junto a un contador de minutos agotándose y una factura, sobre un fondo de servidores

Cómo un cron cada 15 minutos casi me cuesta 12 dólares en GitHub

El jueves el post del Prime Day no se publicó. Y no fue un fallo del código, ni de Cloudflare, ni de Astro. Fue que me había quedado sin minutos de GitHub Actions sin enterarme, porque tenía un cron reconstruyendo el blog entero cada 15 minutos, las 24 horas, todos los días. El medidor se plantó en 12 dólares (que resulta que no me cobran, pero que sí me hicieron sobrepasar el límite) y el blog dejó de actualizarse. Aquí te cuento qué tenía mal montado, cómo lo arreglé, y el truco que uso ahora para que el despliegue solo se ejecute cuando de verdad hay algo que publicar, en vez de gastar a ciegas cada cuarto de hora.

12:54 13 min Marcos Ramírez Lucía
Infraestructura de Cloudflare Workers sobre fondo oscuro con red de nodos distribuidos y código de configuración

De Cloudflare Pages a Workers con Astro: la guerra real

Cuento cómo migré marcosramirez.dev de Cloudflare Pages a Cloudflare Workers en dos días: por qué fue necesario, qué salió mal en cada intento, qué configuraciones rotaron sin funcionar y cómo quedó al final. Si estás usando Astro con el adaptador de Cloudflare y tienes rutas de API con SSR, esto te va a ahorrar tiempo.

08:30 8 min Marcos Ramírez Lucía