Field notes · astro

Scheduling Astro posts without GitHub Actions

When my free GitHub Actions allowance ran out, I moved Astro scheduling to a 20-line Cloudflare Worker that triggers one daily rebuild without CI at all.

·8 min read

The warning was not subtle. I pushed and merged changes, and the CI pipelines across my GitHub repositories did not start. I had exhausted my free GitHub Actions allowance.

I could still run the checks locally and merge based on those results. That was slow and tedious, but it worked. The larger problem was this blog. A scheduled post on a static Astro site does not publish itself when its date arrives. The site has to build again, and I had made GitHub Actions responsible for that rebuild.

I moved that responsibility to Cloudflare, where the blog was already hosted. A small Cron Worker now calls a secret deploy hook once a day. GitHub Actions can still validate changes when it is available, but the publication clock no longer belongs to CI.

Key takeaways

  • A future publication date only affects what the next static build includes.
  • CI validation and scheduled publication are separate responsibilities.
  • A Cloudflare Cron Worker can trigger the host’s build without GitHub Actions.
  • Configuration checks are not proof of a completed automatic publication.

Why do future-dated Astro posts need another build?

A future-dated post on a static Astro site is a build-time decision, not a timer. The generated HTML cannot wake up, notice the date, and add another page. Some external system must request a new build after the publication date arrives. The date changes a post’s eligibility; it does not create an event.

My collection stores a pubDate and a draft flag for every post. During a production build, one helper decides whether a post is eligible:

export function isPublished(post, now = new Date()) {
  if (post.data.draft) return false;
  return post.data.pubDate.valueOf() <= now.valueOf();
}

That function does exactly what I want. Before the date, the post stays out of the blog index, feeds, and generated routes. On or after the date, the next build includes it. The important phrase is the next build.

Astro describes its default output as static, with pages rendered at build time in its rendering modes documentation. The pubDate field is content data. It does not create a scheduler.

This distinction is easy to miss because future dating feels like a publishing feature in a traditional content-management system. A CMS usually has a running application or hosted service watching the clock. A static repository has Markdown and a date. Something else still has to do the watching.

What broke when GitHub Actions stopped running?

The post files and publication filter were fine. The broken dependency was the scheduled rebuild between them and production. When pushes and merges stopped starting their CI pipelines, I could no longer trust Actions to rebuild this site when a post became due. The content was correct, but its publication clock had lost its owner.

GitHub documents that private repositories receive an included Actions quota based on the account plan. It also explains that usage can be blocked after the quota is exhausted when additional spending is unavailable. Those details are in the official GitHub Actions billing documentation.

For ordinary changes, I had an escape hatch: run the sanitization, tests, and Astro build locally, then merge based on those results. I used that path. It was slower than having CI report the result automatically, but the evidence was still available.

Scheduled publication was different. A manual check proves a commit is safe to merge. It does not come back days later and rebuild the site. I could put a calendar reminder on my phone, but then I would be the scheduler. That defeats the reason I write several posts ahead and give them future dates.

The failure exposed a design mistake: I had treated validation and timed publication as one automation problem because both happened to use the same service.

Which replacement options did I consider?

I considered two practical paths: keep doing the work locally, or move the publication clock to Cloudflare. The first was a useful fallback. The second fixed the ownership problem without adding another vendor or another dashboard to check.

Approach What it solved Why I did or did not choose it
Run checks locally and merge manually Let me keep shipping verified changes while Actions was unavailable Safe enough as a fallback, but slow and tedious; it still required me to remember every publication date
Let Cloudflare schedule its own rebuild Kept future-dated publishing beside the platform that already built and hosted the site Chosen because it removed Actions from the timed path without adding another service

I did not need a new content-management system or a general automation platform. Cloudflare already had the Git connection and the build configuration. It only needed a reliable signal telling it when to build. That preference for the platform already in the room is the same constraint-first judgment I used when deploying Flask on IIS because Linux was not an option.

That was the smallest useful change: move the clock, not the whole publishing system. It is also the selective-automation rule I use when provisioning accounts in systems that were never built for me: automate the valuable boundary, not every possible click.

How does the Cloudflare scheduling path work?

Cloudflare now owns one narrow job: once per day, a Cron Worker sends an HTTP POST to a secret deploy hook for the blog’s main branch. The hook starts the normal Cloudflare build, and Astro decides which posts are due. The timer sits beside the platform that already owns the build and hosting path.

The complete path is:

future-dated Markdown -> daily Cloudflare Cron Worker -> secret deploy hook ->
Workers Build from main -> Astro publication filter -> live site

The scheduler itself is deliberately small. Its production source is 20 nonblank lines. It checks that the secret exists, posts to the hook, and turns a network or non-success response into a sanitized error. The implementation code belongs in the next focused article; the important architectural point here is the boundary.

Cloudflare’s Cron Triggers documentation defines the scheduled() handler and notes that cron expressions run in UTC. My checked-in schedule is 15 11 * * *, which requests a rebuild once daily at 11:15 UTC.

Cloudflare’s Deploy Hooks documentation describes the hook as a unique URL that starts a build for one branch when it receives a POST. The URL acts as a credential, so I store it as an encrypted Worker secret. It never belongs in the repository or an error message.

GitHub still has a role. A normal merge to main can trigger Cloudflare’s Git integration, and the Actions workflows remain useful quality gates when they can run. They simply no longer own the daily publishing clock.

How did I verify the new owner actually published?

I verified the parts that could be proven immediately: the Worker was deployed, the production cron was attached, the deploy hook existed as an encrypted secret, public Worker URLs were disabled, and invocation logging was enabled. I also tested the handler’s success and failure paths locally.

The tests cover five behaviors:

  1. The module exposes both a testable scheduler factory and the production scheduled handler.
  2. A scheduled event sends exactly one POST to the configured hook.
  3. A missing secret fails before any request and disables automatic retry.
  4. A non-success response reports only the HTTP status, not the hook URL.
  5. A network failure becomes a generic error that does not leak the secret.

There is an important limit to that verification. At the time I configured it, the first automatic production invocation was still pending. I had verified deployment, configuration, secret handling, logging, and the handler contract. I had not yet watched the daily cron publish a due post.

That distinction matters. A green unit test is not a deployed Worker. A deployed Worker is not proof that its cron fired. A successful hook request is not proof that the build finished. A completed build is not proof that the expected page is live.

Those are separate checkpoints. I would rather document the boundary honestly than turn a sound configuration check into a claim I had not observed.

What would I change next time?

I would separate CI validation from publication scheduling on day one. CI should answer, “Is this change safe to merge?” The hosting platform should answer, “Is it time to rebuild the published site?” The exhausted allowance showed why those questions need independent owners.

That separation would not eliminate local checks. They remain the fallback when hosted CI is unavailable, and they are valuable before every commit. It would eliminate the hidden assumption that one metered service must stay available for an unrelated future event.

I would also write down the verification ladder before deployment:

  1. Test the handler without a real secret.
  2. Deploy the Worker and read back its configuration.
  3. Confirm the cron, secret binding, disabled public route, and logging.
  4. Observe an automatic invocation.
  5. Follow the resulting build through to the live page.

That list keeps me from stopping at “the command succeeded.” It also makes a failure easier to locate. The clock, request, build, content filter, and public page each have one job.

Frequently asked questions

The practical boundary is simple: Astro decides what a build contains, Cloudflare decides when this build runs, and the deploy hook connects them.

Can Astro publish a future-dated post by itself?

Not in a statically generated deployment. Astro can use the date while it builds the site, but the already-generated files do not change when the date arrives. You need another build on or after the publication date.

Does a Cloudflare deploy hook need an Authorization header?

No. Cloudflare’s deploy hook URL contains the unique credential needed to start the build. That convenience makes the URL sensitive: store it as a secret, restrict access to it, and replace it if it is exposed.

What happens if the scheduled rebuild fails?

The due post remains absent until a later successful build includes it. My fallback is to run the checks locally and trigger a normal merge or the manual rebuild workflow. The reliable version of this system also needs logs and a clear way to notice that the expected page did not appear.