予約投稿が出なかった朝:静的サイトの公開日はビルドが決める


8月10日の朝、9時に出るはずの記事がサイトにありませんでした。公開日を未来にしておけば、その日になると自動で表示される。WordPressならそういうものなので、このブログでも当然そうなると思い込んでいたのです。ところが、このブログはAstroで作った完全な静的サイトで、公開日は「ビルドを走らせた瞬間の時計」で決まります。前々日に自動化の仕組みを入れたつもりで、それも動いていませんでした。何が起きていたのか、順番に書きます。同じ構成でブログを作る人が、同じ朝を迎えないように。

静的サイトでは公開日を過ぎても記事は出ず、ビルドをもう一度走らせた時点でようやく現れます。

その朝、何が起きていたか

時系列で並べると、自分の勘違いがよく見えます。全部このブログのGit履歴に残っている記録です。

日時(日本時間)出来事
8月8日 9:14サイトを一般公開
8月8日 17:11予約投稿を自動で出すための定期再ビルド(後述)を追加
8月10日 7:38「8/10の予約投稿を公開するための再ビルド」をpush。記事は出ない
8月10日 9:06同じ目的でもう一度push。記事が出る
8月18日自動再ビルドの設定を確認したら、まだ空だった

7時38分のpushは、いま見返すと出るはずがありませんでした。理由はあとで書きます。9時6分に2回目を押して記事は無事に出たのですが、この時点で「自動で出す仕組み」は一度も働いていません。8日に入れたはずの自動化は、その後も動いた形跡がなく、この記事を書いている8月18日時点でもまだ手で押しています。

静的サイトに「公開日」は存在しない

まず、そもそもの仕組みから。このブログはAstroの静的サイトジェネレーターで作っていて、記事のMarkdownファイルからHTMLをあらかじめ全部生成し、それをCloudflareのWorkersから配信しています。閲覧のたびにサーバーがページを組み立てるのではなく、できあがったファイルを置いているだけです。

Astroには「予約投稿」という機能はありません。あるのは、記事のfrontmatterに書いた pubDate という日付と、それをどう扱うかを自分で書くコードだけです。このブログでは、記事一覧・記事ページ・サイトマップ・RSSが全部通る関数の中で、こう判定しています。

export async function getPublishedPosts() {
  // 未来日付の記事は予約投稿として扱い、ビルド時点で公開日を過ぎたものだけを出す
  const now = Date.now();
  const posts = await getCollection(
    'blog',
    ({ data }) => !data.draft && data.pubDate.valueOf() <= now
  );
  return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}

Date.now() は、この関数が実行された瞬間の時刻です。そしてこの関数はビルドのときにしか実行されません。つまり「公開日を過ぎたかどうか」は、読者がアクセスした時刻ではなく、最後にビルドした時刻で判定されます。8月8日にビルドしたサイトは、8月8日時点で公開日が来ていない記事を含まないまま、9日になっても10日になってもそのまま配信され続けます。公開日を過ぎたら誰かがもう一度ビルドしなければ、記事は永遠に出ません。

書いてしまえば当たり前のことです。ただ、WordPressに慣れた頭には「公開日は記事の属性で、時間が来れば表に出る」という感覚が染みついていて、静的サイトではその感覚がまるごと通用しないことに、記事が出ない朝になるまで気づけませんでした。

自動化したはずが、URLが空だった

もちろん、毎朝手でビルドし直すつもりはありませんでした。8月8日の夕方、Cloudflare WorkersのCron Triggersを使って、毎朝1回だけ自動で再ビルドさせる仕組みを入れています。Cron Triggersは、指定した時刻にWorkerの scheduled ハンドラーを呼び出す機能で、実行時刻はUTC(協定世界時)で指定します※1。

// wrangler.jsonc
"triggers": {
  "crons": ["0 1 * * *"]   // 毎日UTC 1:00 = 日本時間10:00
}

そして呼び出されたWorker側では、Cloudflareの「Deploy Hook」にPOSTを送るだけです。Deploy Hookは、HTTPのPOSTを受け取ると新しいビルドを起動する、一意のURLを発行する仕組みです※2。

// worker/index.js
async scheduled(event, env, ctx) {
  if (!env.DEPLOY_HOOK_URL) return; // 未設定なら何もしない
  ctx.waitUntil(fetch(env.DEPLOY_HOOK_URL, { method: 'POST' }));
}

問題は1行目です。Deploy HookのURLは、コードに直接書くとGitに残ってしまうので、Workersの「シークレット」として別に登録する設計にしました※3。そして、その登録をやっていませんでした。コードを書いてpushした時点で満足して、ダッシュボードでフックを発行してシークレットに入れる、という手作業の工程が抜けていたのです。

この設計のたちが悪いところは、未設定でもエラーが出ないことです。毎朝10時にCronは呼ばれ、scheduled は実行され、URLが空なので静かに return して終わる。ログを見に行かない限り、何も教えてくれません。8月18日に確認したときの出力がこれです。

$ npx wrangler secret list
[]

空配列。8月8日から10日間、自動化は毎朝律儀に起きて、何もせずに寝ていました。

9時の壁:日付だけ書くと、公開は日本時間の朝9時

7時38分のpushが無駄だった理由も、ここで書いておきます。

このブログでは公開日を pubDate: 2026-08-10 のように日付だけで書いています。JavaScriptでは、こうした日付だけの文字列はUTCの0時として解釈されます※4。UTCの0時は日本時間の朝9時です。つまり 2026-08-10 と書いた記事の公開時刻は、日本時間で8月10日の午前9時ちょうどになります。7時38分のビルドでは、Date.now() がまだ公開時刻に届いていないので、コードが正しく判定して記事を除外しました。何も壊れていません。自分が早すぎただけです。

実は、Cronの実行時刻をUTC 1時(日本時間10時)にした理由がまさにこれで、日本時間9時に公開時刻が来る記事を、その後の10時に拾うように設計していました。設計したときは分かっていたのに、朝の焦りで自分の設計を忘れて7時台に押している。時刻の仕様と人間の記憶では、仕様のほうが信用できます。

いま何をしていて、どう直すか

この記事を書いている時点で、自動化は動いていません。8月10日以降に公開した記事は、公開日の朝9時を過ぎてから空コミットをpushして、Workers Buildsを手で起こしています。空コミットでも、Gitと連携したWorkers Buildsはビルドを走らせてくれるので、これが最も手軽な回避策になっています。

直すのに必要な作業は、実は数分です。

  1. Cloudflareダッシュボードで、このWorkerのBuilds設定からDeploy Hookを発行する
  2. 発行されたURLを wrangler secret put DEPLOY_HOOK_URL でシークレットに登録する
  3. wrangler secret listDEPLOY_HOOK_URL が出ることを確認する

やっていない理由は、優先度を下げたまま忘れていた、それだけ。この記事は、その工程を自分に思い出させるために書いたようなものです。次に予約投稿を仕込む記事の公開日までには終えておくつもりで、終わったらこの記事の末尾に追記します。

もうひとつ、地味に効いた教訓。「未設定なら何もしない」という安全策は、書いた瞬間は親切に見えます。ただ、その安全策のせいで、動いていないことに10日間気づきませんでした。未設定のときにエラーを吐くようにしておけば、Cronの初回実行でログに赤い行が出て、8月9日の朝には気づいていたはずです。静かに失敗するコードは、忘れっぽい人間には向いていません。

追記(8月18日夜):直しました

この記事を公開した日の夜に、上の3手順を済ませました。ダッシュボードでDeploy Hookを発行し、URLをシークレットに登録し、wrangler secret listDEPLOY_HOOK_URL が出ることを確認。試しにフックへ手でPOSTすると、ビルドがキューに入ったという応答が返ってきました。翌19日の朝9時公開で1本予約してあるので、10時のCronがそれを自動で出せば、この仕組みは初めて本物の仕事をしたことになります。結果はここに足します。

参考文献・出典

※1 : 「Cron Triggers · Cloudflare Workers docs」 |Cloudflare Docs https://developers.cloudflare.com/workers/configuration/cron-triggers/

※2 : 「Deploy Hooks · Cloudflare Workers docs」 |Cloudflare Docs https://developers.cloudflare.com/workers/ci-cd/builds/deploy-hooks/

※3 : 「Secrets · Cloudflare Workers docs」 |Cloudflare Docs https://developers.cloudflare.com/workers/configuration/secrets/

※4 : 「Date - JavaScript | MDN」 |MDN Web Docs https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date

よくある質問

Astroの予約投稿はどう動きますか?

Astroの静的サイトには予約投稿の機能そのものはありません。ビルド時点の時刻と記事の公開日を比べて、まだ来ていない記事を一覧やページ生成から除外するコードを自分で書き、公開日を過ぎたあとにもう一度ビルドを走らせる必要があります。ビルドが走らなければ、公開日を過ぎても記事は現れません。

公開日を「2026-08-10」のように日付だけで書くと、何時に公開されますか?

JavaScriptでは日付だけの文字列は協定世界時(UTC)の0時として解釈されるため、日本時間では同じ日の朝9時になります。朝9時より前にビルドしても、その日の記事は出ません。