チュートリアル
静的な Astro サイトでブログ記事を予約公開する
· 読了まで約10分
目次
予約公開は、人を静的サイトから静かに遠ざける機能の1つです。CMS なら日付ピッカー1つで済む。静的サイトには無理だろう、というのがよくある推論です。HTML はビルド時に焼き固められ、時計を見張るサーバーもいない。寝ている間に記事を出したければ、SSR かヘッドレス CMS か、どこかの公開系 SaaS が要る、と。
要りません。このブログは完全に静的で(プリレンダリングした Astro を Cloudflare Workers からファイルとして配信、データベース無し)、記事はすべて予約公開です。数日先まで書きためて未来の日付を付けておけば、深夜0時に勝手に公開されます。仕組みの全体は、content collections のフィルタ1つと、スケジュール実行の GitHub Actions ワークフロー1本。この記事で両方を通しで説明します。素直に日付を比較すると踏むタイムゾーンのバグも含めて。
考え方: 今日の日付をビルドの入力にする
静的ビルドは関数です。コンテンツを入れると HTML が出てくる。コツは、今日の日付を入力の1つにすることです。やることは2つ。
- ビルド時に、
pubDateが未来の記事をすべて除外する - スケジュールで再ビルドする。1日1回、記事を出したい時刻に
毎日のビルドが新しい「今日」でフィルタを評価し直すので、明日の日付が付いた記事は今夜のビルドには見えず、明日のビルドには現れます。リクエスト時に時計を見張るものは何もなく、時計はビルドごとに1回だけ参照されます。日次の公開ペースには、それでちょうど足ります。
見落とされがちなのは2つ目のほうです。フィルタを書くのは簡単ですが、静的サイトは日付が過ぎても勝手に再ビルドされません。未来の pubDate は、その日が来た後に何かがビルドを走らせるまで何もしない。日付に意味を与えているのは、スケジュール実行のワークフローのほうです。
Step 1: 未公開の記事をすべての場所でフィルタする
Astro の content collections なら、フィルタは呼び出し側の1行で済みます。決めることは述語をどこに置くかくらいで、私は全ページが同じ定義を使えるよう src/consts.ts に置いています。
// コレクションのフィルタ: 下書きは `pnpm dev` ではプレビュー用に見え、ビルドからは除外される
export function publishedOnly({ data }: { data: { draft: boolean; pubDate: Date } }): boolean {
return import.meta.env.DEV || (!data.draft && data.pubDate.getTime() <= todayInJst());
}
記事を一覧するすべての場所で、これを使います。
import { getCollection } from 'astro:content';
import { publishedOnly } from '../consts';
const posts = (await getCollection('blog', publishedOnly))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
この述語には、意図的な選択が2つ入っています。
import.meta.env.DEV が全体を短絡させます。 pnpm dev では未来日付の記事も下書きも普通に描画されるので、予約済みの記事を本番と同じ URL でプレビューできます。フィルタが効くのは本番ビルドだけ。これが無いと、自分の文章を読み返すためだけに日付を一時的に書き換えることになり、その手間こそが校正半ばの記事を世に出す原因になります。
draft と未来の pubDate は別物です。 未来の日付は「書き上がっていて、その日を待っている」。draft: true は「まだ書き上がっていない」。そして日付が過ぎても draft が勝ちます。楽観的な日付を付けた書きかけの記事が、忘れたころに本番へ漏れる事故は起きません。私のスキーマは draft の既定値を false にしています。予約公開のほうが日常なので、そちらを短く書けるようにしました。
フィルタは、記事が顔を出すすべての場所に当てる必要があります。ブログの一覧だけでなく、タグページ、RSS フィード、sitemap、JSON-LD、llms.txt の索引、関連記事のリスト。1か所でも漏らすと、トップページには出ていないのに RSS には載っている、という状態になり、フィードリーダーが嬉々として未公開記事を予告します。述語を1つに共有しておけば、これは祈りではなく grep で確かめられる保証になります。
タイムゾーンの罠
最初の実装でまず踏むのがこのバグです。YAML フロントマターの素の日付は
pubDate: 2026-08-04
UTC の深夜0時としてパースされます。これを Date.now() と比較すると、8月4日付の記事は UTC の0時、つまり東京では8月4日の朝9時に公開されます。ロサンゼルスならまだ8月3日の午後5時です。UTC のどちら側に住んでいるかによって、記事は気恥ずかしいほど遅れて出るか、1日早く出ます。
直し方はこうです。フロントマターの日付がどのタイムゾーンを指すのかをまず決め、現在時刻をそのゾーンへずらしてから日付に切り詰めます。
/** 今日の JST 深夜0時を、そのカレンダー日付の UTC タイムスタンプとして返す */
function todayInJst(): number {
const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
return new Date(Date.now() + JST_OFFSET_MS).setUTCHours(0, 0, 0, 0);
}
これで比較の両辺が同じ土俵に乗ります。pubDate は「書いた日付の UTC 0時」、todayInJst() は「日本で今日にあたる日付の UTC 0時」。記事は、自分のタイムゾーンでその日が始まった瞬間に現れます。オフセットは自分の地域のものに差し替えてください(こんな単純な定数で済むのは日本にサマータイムが無いからで、ある地域なら Intl.DateTimeFormat に timeZone を渡してローカルの日付を取るほうが安全です)。
Step 2: 日次リビルド
ワークフローは短いです。私はビルドした出力を wrangler で Cloudflare Workers にデプロイしていますが、デプロイのステップは使っているホストに合わせて読み替えてください。本体は schedule トリガーです。
name: Deploy
on:
schedule:
# UTC 15:00 = 翌日の JST 00:00
- cron: '0 15 * * *'
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.14.4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
そのまま持っていってよい細部が3つあります。
- cron は UTC です。
0 15 * * *が JST の深夜0時。狙う時刻を UTC に換算して、コメントに書いておいてください。3か月後には必ず忘れています。 workflow_dispatchが非常口になります。 公開済みの記事に誤字を見つけたら、明日の定時を待たずに Actions タブから同じワークフローを手で叩けます(私の構成では main への push はデプロイしません。push 時の CI はチェックとビルドだけで、配信するのはこのワークフローだけ。だから手動実行が最速の経路です)。concurrencyとcancel-in-progress: falseの組み合わせで、手動実行とスケジュール実行が重なっても二重にデプロイされることがなく、かつ進行中のデプロイを途中で打ち切ることもありません。
Cloudflare を使っているなら、Workers へのデプロイ一式と、新規サイトで Pages ではなく Workers を選ぶ理由を別記事にまとめてあります。Actions でビルドして wrangler deploy で送ると、スケジュールの再ビルドがホスト側のビルド時間を消費しないという副産物も付いてきます。
GitHub の cron の注意書き
スケジュール実行を当てにする前に、GitHub の cron の癖を知っておいてください。
時刻はずれます。 GitHub はスケジュール実行をキューに積み、余裕ができたら開始します。普段は数分遅れ、混雑する時間帯には10分以上ずれることもあります(毎時0分が最悪で、:07 や :23 にずらすと多少ましになります)。「夜のうちに記事が出る」用途なら誤差の内です。分単位の正確さが要るなら、静的リビルドは正直、道具が違います。
動きの無いリポジトリはスケジュールを止められます。 公開リポジトリで60日間活動が無いと、GitHub は cron ワークフローを無効化します(事前にメールは来ます)。書き続けているブログなら記事がすべてコミットなので当たりませんが、ひと季節放置したサイトは静かに公開が止まります。カレンダーに印を付けておくか、何でもよいのでコミットすればリセットされます。
どちらもブログには実害が無く、どちらも初見では驚きます。
オンデマンドのトリガーではだめなのか
凝ろうと思えば凝れます。Cloudflare Worker の Cron Trigger からデプロイフックを叩くとか、今日公開日を迎える記事があるかを調べて、無ければビルドを飛ばすとか。両方検討したうえで、意図的に馬鹿正直な版を残しました。
毎日無条件に再ビルドしても、この規模のサイトなら1日あたりビルド2分ほど。無料枠に余裕で収まるうえ、鮮度チェックを兼ねてくれます。依存やビルドステップが壊れても、読者に教わる前に翌朝の赤い ✗ メールで気づける。公開するものが無い日のビルドを省くと、節約できるのは小銭で、失うのはこのシグナルです。静的サイトの強みは退屈さにあり、公開パイプラインはいちばん退屈であるべきです。
全体像
- 記事はフロントマターに
pubDateを持ち、スキーマがDateに変換する - 共有の述語
publishedOnlyが、すべてのコレクション取得(ページ、タグ、RSS、sitemap、構造化データ)をフィルタする。比較相手は UTC ではなく自分のタイムゾーンの0時 - dev モードは全部見せ、本番ビルドは過去だけをビルドする
- GitHub Actions の日次 cron が再ビルドとデプロイを行い、日付の境界を公開イベントに変える。「今すぐ出したい」は
workflow_dispatchが受け持つ
予約公開は、静的サイトには持てないはずの機能でした。実際には合計40行ほどで、そのどれもリクエスト時には動きません。つまり、リクエスト時に落ちることもありません。「日曜に3本書いて、火・水・木に1本ずつ出す」が手放しで回るのを一度経験すると、手動の公開には戻れなくなります。