個人開発

Astro の静的ブログに予約投稿を実装する — Cloudflare Workers Cron + Deploy Hook

カレンダーのイメージ
Photo by Matheus Bertelli on Pexels

静的ブログで予約投稿したいが、仕組みがない

このブログは Astro で構築していて、Cloudflare Workers Static Assets にデプロイしています。記事はすべて Markdown ファイルで管理していて、GitHub に push すると自動でビルド&デプロイされる構成です。

WordPress や note のような CMS サービスなら「公開日時を設定して保存」するだけで予約投稿できますが、静的サイトジェネレーターにはその仕組みがありません。ビルドした時点のデータがそのまま公開されるので、「未来の記事を仕込んでおいて、当日になったら勝手に公開される」という挙動を自分で作る必要があります

最初に試した方法: GitHub Actions で draft フラグを書き換える

最初に考えたのは、記事の frontmatter にある draft: true を GitHub Actions の cron で draft: false に書き換えて push する方式でした。

# .github/workflows/scheduled-publish.yml
name: Scheduled Publish
on:
  schedule:
    - cron: '0 15 * * *'  # UTC 15:00 = JST 0:00
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Publish scheduled drafts
        shell: bash
        run: |
          TODAY=$(TZ=Asia/Tokyo date +%Y-%m-%d)
          TODAY_NUM=$(TZ=Asia/Tokyo date +%Y%m%d)
          for file in src/blog/*.md; do
            grep -q "^draft: true" "$file" || continue
            PUB_DATE=$(grep "^pubDate:" "$file" | awk '{print $2}')
            PUB_NUM=$(echo "$PUB_DATE" | tr -d '-')
            if [ "$PUB_NUM" -le "$TODAY_NUM" ]; then
              sed -i 's/^draft: true/draft: false/' "$file"
            fi
          done
          # 変更があれば commit & push

発想はシンプルですが、ここからハマりポイントが3連発 でした。

ハマり1: シェルの比較演算子

最初 [ "$PUB_DATE" <= "$TODAY" ] で日付比較していたのですが、[ ] では <= が使えません。[[ ]] に書き換えて対処しましたが、最終的には日付を数値化(20260418 のような形式)して比較する方式に落ち着きました。

ハマり2: UTC と JST のズレ

GitHub Actions の cron は UTC 基準で動きます。0 15 * * *(UTC 15:00 = JST 0:00)で設定して、date コマンドで日付を取得していましたが、date -u だと UTC の日付が返ってくるので、JST 基準では「昨日」になってしまう ケースがありました。

TZ=Asia/Tokyo date に修正して対処しましたが、タイムゾーンの罠は地味に気づきにくいです。

ハマり3: GitHub Actions の cron はそもそも時間通りに動かない

これが一番の問題でした。GitHub Actions の cron は実行タイミングが保証されていません。GitHub の公式ドキュメントにも明記されています。

実際の挙動としては:

  • 通常でも 5〜20分の遅延
  • 深夜 UTC 0:00 前後は 20〜30分遅れる ことも
  • 高負荷時はジョブが 実行されない(ドロップする) 場合もある

「0:00 に公開したいのに 0:30 まで出ない」は個人ブログなら許容範囲かもしれませんが、ジョブ自体が実行されない可能性がある のは予約投稿の仕組みとしては致命的でした。

draft フラグの設計ミスにも気づいた

GitHub Actions 方式をデバッグしている過程で、もうひとつの問題に気づきました。draft フラグを「未完成の下書き」と「予約投稿」の2つの意味で使っていた ことです。

  • draft: true + pubDate が未来 → 予約投稿
  • draft: true + pubDate が過去 → 書きかけの下書き

この区別がコード上で曖昧になっていて、予約投稿を処理するスクリプトが下書きまで公開してしまうリスクがありました。

方針転換: もっとシンプルにできるはず

ここまでの問題を整理すると:

  1. GitHub Actions の cron は精度・信頼性に課題がある
  2. draft フラグの書き換え → push → リビルドは回りくどい
  3. draft フラグに2つの意味を持たせるのは設計がよくない

そこで 「ビルド時に pubDate で判定すればいいのでは?」 という発想に切り替えました。

現在の方式: pubDate ビルド時フィルタ + Cron Worker + Deploy Hook

最終的に落ち着いたのがこの構成です。

予約投稿のアーキテクチャ図

仕組みはシンプルで、3つのパーツで構成されています:

  1. Astro ビルド時のフィルタ: pubDate <= 現在時刻(JST) の記事のみ表示
  2. Cloudflare Workers Cron Trigger: 毎日 JST 0:00 に発火
  3. Deploy Hook: Cron Trigger が Cloudflare Workers Builds の Deploy Hook を叩いてリビルド

draft フラグは純粋に「未完成の下書き」だけの意味に整理しました。予約投稿は draft: false + pubDate: 未来の日付 で表現します。

Astro 側: pubDate でビルド時にフィルタする

記事一覧を取得するすべての箇所で、以下のフィルタを入れています。

const isDev = import.meta.env.DEV;
const jstNow = Date.now() + 9 * 3600_000;

const posts = (await getCollection('blog', ({ data }) =>
  isDev || (!data.draft && data.pubDate.valueOf() <= jstNow)
)).sort((a, b) =>
  b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

ポイントは3つです。

  • JST 基準で判定: Date.now() + 9 * 3600_000 で UTC を JST に変換。Cloudflare のビルド環境は UTC なので、この補正がないと日本時間の当日記事が出ません
  • dev 環境では全記事表示: import.meta.env.DEV が true のときはフィルタを無効にして、プレビューで未来の記事も確認できるようにしています
  • draft は下書き専用: !data.draft で未完成記事を除外しつつ、予約記事は draft: false なのでフィルタを通過します

このフィルタは記事一覧(index.astro)、個別記事ページ([...slug].astro)、タグページ([tag].astro)、RSS(rss.xml.ts)の4箇所に入れています。

Cloudflare Workers: 毎日 JST 0:00 にリビルドする Cron Worker

Cron Worker のコードは驚くほどシンプルです。

// workers/deploy-trigger/src/index.ts
interface Env {
  DEPLOY_HOOK_URL: string;
}

export default {
  async scheduled(_event: ScheduledEvent, env: Env) {
    await fetch(env.DEPLOY_HOOK_URL, { method: 'POST' });
  },
};
# workers/deploy-trigger/wrangler.toml
name = "techikoma-deploy-trigger"
main = "src/index.ts"
compatibility_date = "2026-04-13"

[triggers]
crons = ["0 15 * * *"]  # UTC 15:00 = JST 0:00

DEPLOY_HOOK_URL は Cloudflare のダッシュボードで Worker の環境変数として設定しています。

GitHub Actions の cron が ±30分 の精度だったのに対して、Cloudflare Workers の Cron Trigger は ±30秒の精度 で発火します。信頼性もクラウドフレアのエッジで実行されるので段違いです。しかも無料枠で利用可能。

Deploy Hook の設定

Cloudflare Workers Builds の Deploy Hook は、ダッシュボードから発行できます。

  1. Cloudflare ダッシュボード → Workers & Pages → 対象プロジェクト
  2. Settings → Builds → Deploy Hooks
  3. 「Add deploy hook」で Webhook URL を生成
  4. その URL を Cron Worker の環境変数 DEPLOY_HOOK_URL に設定

Deploy Hook を POST すると Workers Builds が走り、Astro のビルドが実行されます。このビルドの中で pubDate フィルタが動き、当日になった記事が公開される、という流れです。

運用フロー

実際の運用はとてもシンプルです。

  1. 記事を書く(src/blog/xxx.md
  2. frontmatter に未来の日付を設定:
    pubDate: 2026-04-20
    draft: false
  3. git push する
  4. あとは放置。JST 0:00 のリビルドで勝手に公開される

すぐに公開したい場合は pubDate を今日の日付にして push するだけです。特別な操作は必要ありません。

まとめ

静的ブログの予約投稿、最初は GitHub Actions の cron で draft フラグを書き換える方式を試しましたが、shell の罠・タイムゾーンの罠・cron の精度の問題と3つハマって、最終的に Cloudflare Workers Cron + Deploy Hook の方式に落ち着きました。

コード量は Worker が10行、Astro 側のフィルタが4箇所に各3行だけ。全体の仕組みがシンプルなぶん、壊れる箇所が少ない のが気に入っています。

Astro に限らず、Hugo や Next.js (Static Export) など静的サイトジェネレーター全般で応用できるパターンなので、予約投稿を実装したい人の参考になれば。


参照リンク