コンテンツ管理 2026年9月13日

publishedAt: 2026-08-11 と書いた記事が、ビルドする機械しだいで8月10日になる。日付だけの値が「瞬間」に変わっていた場所を実測した話

XECIN AstroCI/CD技術検証

きっかけは、別案件のバッチ処理で timestamptz の日付境界に噛まれたことでした。

日次集計の「今日」が、DB の時計とアプリの時計で 9 時間ずれていて、深夜 0 時から 9 時までの間だけ前日に計上される。原因を潰したあとで、ふと自社サイトのことが気になったんですよね。うちのテックブログは、記事の日付を publishedAt: 2026-08-11 のように日付だけで書いています。時刻もタイムゾーンも書いていない。この値は、ビルドのどこかで一度「瞬間」になっていないだろうか。なっているなら、どこかの時計で「日付」に戻されているはずで、その時計は誰のものなのか。

私はこういう「値が途中で型を変えているかもしれない」話を放っておけない性分でして、週末に手元で確かめました。結論から言うと、変わっていました。しかも本番が正しく表示されているのは、設計ではなく偶然でした。

まず、どこで日付が瞬間になっているか

サイトは Astro 6 の Content Collections で、記事は MDX、フロントマターは Zod スキーマで検証しています。日付の定義は z.coerce.date() です。coerce なので、文字列でも Date でも受け取って Date に揃える。書きやすさを優先した定義で、私自身これに文句はありません。

問題は、2026-08-11 という日付だけの文字列を Date にすると何が起きるかです。ECMAScript の仕様では、日付だけの ISO 形式は UTC の 0 時として解釈されます。つまり 2026-08-11 は「8 月 11 日」という暦の上の 1 日ではなく、「2026-08-11T00:00:00Z」という世界共通の一瞬になる。日本時間で言えば 11 日の朝 9 時、ロサンゼルスなら 10 日の午後 5 時です。

これは推測ではなく、ビルド後に残るデータストアを読めば確認できます。

# 取り込み前: フロントマターは全記事が「日付だけ」
$ grep -rhE '^(publishedAt|updatedAt):' src/content | sed -E 's/[0-9]{4}-[0-9]{2}-[0-9]{2}/D/' | sort | uniq -c
    102 publishedAt: D          # 時刻付き・スラッシュ区切りは 0 件

# 取り込み後: データストアには「瞬間」として保存されている
$ grep -o '\["Date","[^"]*"\]' node_modules/.astro/data-store.json | head -3
["Date","2026-04-20T00:00:00.000Z"]
["Date","2026-06-14T00:00:00.000Z"]
["Date","2026-03-30T00:00:00.000Z"]
$ grep -o '\["Date","[^"]*"\]' node_modules/.astro/data-store.json | wc -l
101

# Astro が自動生成する JSON Schema も、この列を date-time だと言っている
$ grep -A2 '"publishedAt"' .astro/collections/news.schema.json
    "publishedAt": {
      "type": "string",
      "format": "date-time"

# 表示側: Date を日付文字列に戻している場所は 5 箇所。timeZone の指定は無い
$ grep -rn "toLocaleDateString" src/components/sections/
NewsArticle.astro:15:   entry.data.publishedAt.toLocaleDateString("ja-JP", { year: "numeric", month: "long", day: "numeric" })
NewsList.astro:58:      featured.data.publishedAt.toLocaleDateString('ja-JP')
NewsList.astro:91:      article.data.publishedAt.toLocaleDateString('ja-JP')
RescueArticle.astro:17: entry.data.publishedAt.toLocaleDateString("ja-JP", { ... })
RescueArticle.astro:22: entry.data.updatedAt?.toLocaleDateString("ja-JP", { ... })

流れを追うとこうなります。フロントマターの「暦の日付」が、取り込み時に UTC 0 時の「瞬間」になり、表示時に toLocaleDateString で再び「暦の日付」に戻る。そして toLocaleDateString は timeZone を渡さなければ、そのコードを実行している機械のタイムゾーンで日付を切ります。書いた人の時計でも、読む人の時計でもなく、ビルドを走らせた機械の時計です。

ここまでは仕様を読めば分かる話です。ただ、正直なところ「分かる」と「ずれる」の間には距離があって、うちの環境で実際にずれるのかどうかは、動かしてみないと言えません。

三つのタイムゾーンでビルドして diff を取る

やったことは単純で、同じコミットを異なる TZ でビルドして、出力ディレクトリを丸ごと比較しただけです。基準は開発機の JST。比較対象は、GitHub Actions のランナーと同じ UTC と、負のオフセットの代表として UTC-7(ロサンゼルス)。

# 基準: 開発機そのまま(Asia/Tokyo)
npx astro build && cp -r dist dist-jst

# CI と同じ条件
TZ=UTC npx astro build && cp -r dist dist-utc

# 負のオフセット(UTC-7 / 夏時間中のロサンゼルス)
TZ=PST8PDT npx astro build && cp -r dist dist-la

diff -rq dist-jst dist-utc | wc -l
diff -rq dist-jst dist-la  | wc -l
diff -rq dist-jst dist-la  | sed 's/.* and //; s/ differ//' | cut -d/ -f2 | sort | uniq -c

# 実行結果:
#   112 page(s) built(3 回とも同じページ数、各 8 秒前後)
#   dist-jst vs dist-utc : 0
#   dist-jst vs dist-la  : 86
#        76 news        ← 記事ページ 76 本すべて
#         1 news/index  ← 一覧。並んでいる 76 個の日付が 76 個ともずれている
#         9 wp-rescue   ← 別コレクション(rescue)の 9 本すべて
#
#   差分の中身(記事ページの 1 箇所だけ抜粋):
#   < <span class="dc-date">2026年8月11日</span>
#   > <span class="dc-date">2026年8月10日</span>
#   同じ HTML 内の og メタは両方とも変わらない:
#     <meta property="article:published_time" content="2026-08-11T00:00:00.000Z">

JST と UTC は完全に一致しました。ここが「本番は偶然正しい」の根拠です。UTC 0 時の瞬間を UTC で日付に戻せば同じ日ですし、JST(UTC+9)で戻しても朝 9 時なので同じ日。開発機が日本にあり、CI が UTC で動いている限り、この不具合は誰の画面にも出ません。

UTC-7 では 86 ファイルが変わりました。記事ページ 76 本すべて、一覧の 76 個の日付すべて、そして表示コンポーネントが別物である wp-rescue の 9 本まで。2025 年 3 月の記事も同じようにずれているので、夏時間の境界とは関係なく、負のオフセットなら無条件で 1 日前になります。

気に入らないのは、同じ HTML の中で article:published_time は 2026-08-11T00:00:00.000Z のまま、見える日付だけが 10 日になっていることでした。機械が読む日付と人が読む日付が、1 ファイルの中で食い違う。検索エンジンが「この記事は 11 日公開と書いてあるのに本文は 10 日」と受け取る状態です。

ビルドした機械の TZUTC 0 時をその時計で読むとpublishedAt: 2026-09-13 の表示うちで該当する環境
Asia/Tokyo(UTC+9)同日 09:002026/9/13開発機(Windows)
UTC同日 00:002026/9/13GitHub Actions(ubuntu-latest)= 本番
Pacific/Kiritimati(UTC+14)同日 14:002026/9/13なし(正のオフセットは常に無事)
America/Sao_Paulo(UTC-3)前日 21:002026/9/12なし
PST8PDT(UTC-7)前日 17:002026/9/12なし(今回の実験でだけ再現)

「該当する環境なし」なら放っておいてもいい、という考え方もあります。ここは好みが分かれるところですが、私は放っておかない側です。理由は後述しますが、「今のランナーが UTC だから」という前提はどこにも書かれておらず、ランナーを変えた瞬間に全記事の日付が黙って 1 日ずれる。しかもビルドは成功し、CI は緑のままです。

最初の実験は「差分なし」だった

白状すると、上の結果に辿り着く前に一度、間違った結論を出しかけています。

最初は TZ=America/Los_Angeles npx astro build と打ちました。IANA 名で書くのが普通ですし、Node は Windows でも IANA 名を解釈します。結果は diff が 0 行。「なんだ、Astro が取り込み時にどこかで正規化しているのか」と思って、そのまま「安全でした」と書きかけました。

引っかかったのは、ビルドログの時刻です。ロサンゼルス時間なら前日の夜のはずが、ログには日本時間のままの 12:06:38 が出ている。TZ が効いていない。

# Git Bash(MSYS)から Windows ネイティブのプロセスを起動したとき
$ TZ=America/Los_Angeles env | grep ^TZ
TZ=America/Los_Angeles                  # シェルの中では確かに入っている
$ TZ=America/Los_Angeles cmd //c "echo %TZ%"
%TZ%                                    # cmd には届いていない(未定義)
$ TZ=America/Los_Angeles node -p "process.env.TZ"
undefined                               # node にも届いていない

# スラッシュを含まない POSIX 形式なら届く
$ TZ=PST8PDT node -p "process.env.TZ + ' -> ' + Intl.DateTimeFormat().resolvedOptions().timeZone"
PST8PDT -> America/Los_Angeles

# PowerShell から渡せば IANA 名でも届く
PS> $env:TZ='America/Los_Angeles'; node -p "Intl.DateTimeFormat().resolvedOptions().timeZone"
America/Los_Angeles

Windows の Git Bash は、環境変数の値がパスに見えると変換をかけます。America/Los_Angeles はスラッシュを含むので、その処理に巻き込まれて子プロセスに渡らない。今思えば、env でシェル内を見て安心したのがよくなかった。検査したい相手(node)が受け取った値を確認する、これに尽きます。

この寄り道は本題とは関係ない Windows 固有の話なのですが、ここで止まっていたら記事の結論が逆になっていたので、あえて残しておきます。「TZ を変えて diff が 0」は、TZ が変わっていなければ何の証明にもならないんですよね。

直し方を四つ並べて、一つ選ぶ

原因は「暦の日付を瞬間に変えて、別の時計で暦に戻している」ことなので、直し方は二系統あります。時計を揃えるか、そもそも瞬間にしないか。

案やること効く範囲引っかかる点
1. 表示側で timeZone を UTC に固定5 箇所の toLocaleDateString に timeZone: “UTC” を渡すどの機械でビルドしても同じ入力が「日付だけ」であることが前提。時刻付きの値が混ざると UTC で日付を切ることになる
2. ビルド環境の TZ を固定ワークフローの env に TZ: Asia/TokyoCI だけ開発機やプレビューには効かない。コードを読んでも前提が見えない
3. スキーマで文字列のまま持つz.string() + YYYY-MM-DD の正規表現にして、表示は文字列を整形瞬間に変換しないので TZ が消える並び替え・og メタ・型定義を使っている全箇所に手が入る。定義ファイルは変更禁止の運用ルールもある
4. Temporal.PlainDate「暦の日付」を型で表す意図が型に残る。本命既定で使えるのは Node 26 から。サイトの engines は 22 以上で、LTS 待ち

選んだのは 1 です。理屈は「取り込み側が UTC 0 時で瞬間を作っているのだから、表示側も UTC で日付に戻せば往復で元に戻る」。取り込みと表示の時計を同じにする、それだけです。

---
// 最初のアプローチ(現状): 実行環境の時計で日付を切る
const dateStr = entry.data.publishedAt.toLocaleDateString("ja-JP", {
  year: "numeric", month: "long", day: "numeric",
});

// 改善後: 取り込み側と同じ UTC で切る。
// フロントマターは「日付だけ」であること(時刻を書かない)が前提。
const dateStrFixed = entry.data.publishedAt.toLocaleDateString("ja-JP", {
  timeZone: "UTC",
  year: "numeric", month: "long", day: "numeric",
});
---

<!-- 検算: 5 箇所すべてに timeZone: "UTC" を入れて UTC-7 でビルドし直す -->
<!--   TZ=PST8PDT npx astro build && diff -rq dist-jst dist | wc -l -->
<!--   実行結果: 0(JST 基準と完全一致。112 page(s) built)              -->

4 を選ばなかった理由も書いておきます。Temporal.PlainDate は「この値はタイムゾーン変換されるべきではない」という意図をそのまま型にできるので、本来はこれが正解です。ただ、既定で有効になるのが Node 26 で、サイトの engines は 22 以上、ランナーの LTS もまだそこにない。ポリフィルを足してまで入れるほど、この 5 箇所は複雑ではありません。「今は 1 で往復を揃えておき、Node の LTS が上がったら 4 に寄せる」と決めて、判断の理由を残しておく方が、後で読む人に親切だと思っています。

3 を選ばなかったのは、影響範囲です。文字列のまま持てば瞬間には二度とならず、根本的には一番きれいです。ただ、一覧の並び替えは getTime() で比較していて、og メタは toISOString() を呼んでいる。スキーマ定義ファイルを触るとそれら全部に手が入ります。うちの運用ではコンテンツ追加の PR でスキーマを触らないルールにしているので、これは別枠の改修として切り出すべきものでした。

2 は「効くけれど見えない」ので、単体では採りません。ただし 1 と組み合わせる分には害がなく、ログの時刻が日本時間になる副次的な利点もあります。

直したあとに残しておく検査

直しただけだと、次に toLocaleDateString を書く人が timeZone を忘れた時点で戻ります。私も忘れる自信があります。

なので検査を二つ持つことにしました。一つは「入力が日付だけである」ことの確認。1 の直し方は日付だけの入力が前提なので、この前提が破れたら直し方ごと見直しが要る。もう一つは、負のオフセットでビルドして基準と比較する、今回の実験そのものです。

#!/usr/bin/env bash
# 日付契約の検査(ローカル用の素案。改善の余地あり)
set -euo pipefail

# 1) フロントマターの日付は「YYYY-MM-DD だけ」か。時刻付きが 1 件でもあれば止める
bad=$(grep -rhE '^(publishedAt|updatedAt):' src/content \
      | grep -vE '^(publishedAt|updatedAt): [0-9]{4}-[0-9]{2}-[0-9]{2}$' || true)
[ -z "$bad" ] || { echo "date-only 契約違反:"; echo "$bad"; exit 1; }

# 2) 表示側の toLocaleDateString は全部 timeZone を渡しているか
total=$(grep -rho "toLocaleDateString" src/components | wc -l)
withtz=$(grep -rhoz "toLocaleDateString([^)]*timeZone" src/components | tr -d '\0' | grep -c toLocaleDateString || true)
[ "$total" -eq "$withtz" ] || { echo "timeZone 未指定: $((total-withtz)) / $total"; exit 1; }

# 3) 負のオフセットでもう一度ビルドし、基準と比較する(ビルド時間が倍になる)
npx astro build >/dev/null && cp -r dist /tmp/dist-base
TZ=PST8PDT npx astro build >/dev/null
n=$(diff -rq /tmp/dist-base dist | wc -l)
[ "$n" -eq 0 ] || { echo "TZ 依存の出力差分: $n ファイル"; exit 1; }

# 実行結果(直す前)  : timeZone 未指定: 5 / 5 で停止(正しい)
# 実行結果(直した後): timeZone 未指定: 2 / 5 で停止(誤報。引数が複数行に
#                       割れている 2 箇所を 2) が数え漏らした)
# 実行結果(3 だけ単独): 差分 0。ビルド 2 回で約 16 秒

このスクリプトには正直なところ、改善の余地がいくつも残っています。2) の正規表現は引数が複数行に割れると数え漏らします。実際、直した後に走らせたら NewsArticle と RescueArticle の複数行の呼び出しを見落として「2 / 5 が未指定」と誤報し、そこで止まりました。3) は TZ が子プロセスに届かない環境(さっきの Git Bash)では常に 0 で通過してしまう。つまり検査自体が今回と同じ罠にかかります。TZ を渡した先で Intl.DateTimeFormat().resolvedOptions().timeZone を出力させて、意図した値になっていることを検査の前提として確認する 1 行が要る。まだ CI にも載せていません。

それでも、この検査を持っているのと持っていないのでは、ランナーを変えるときの安心感が違います。「UTC で動いているから正しい」ではなく「どの TZ で動いても同じ出力になる」を、動かして確かめられる状態にしておきたい。

振り返って

今回の学びは技術というより、値の扱い方の話でした。

2026-08-11 は暦の上の 1 日です。「その日のどの瞬間か」という情報は最初から存在しない。それを Date にした時点で、存在しなかった情報(UTC 0 時)が付け足され、表示の時点で、また別の情報(ビルド機の時計)が使われて削られる。付け足す時計と削る時計が同じなら元に戻り、違えば 1 日ずれる。うちの本番は「同じ」側に偶然いただけで、どこにもそう書いていなかった。

別案件の timestamptz の件と構造は同じでした。あちらは DB とアプリで時計が違い、こちらは取り込みと表示で時計が違う。値が「日付」から「瞬間」に型を変える場所を一度探して、変える時計と戻す時計が同じかを確かめる。この手順さえ持っていれば、ライブラリや言語が変わっても通用します。

今後は、まず 5 箇所に timeZone: "UTC" を入れる PR を別途出して、上の検査をローカルの手順に組み込みます。その先で Node の LTS が 26 に上がったら、Temporal.PlainDate で「暦の日付」を型として持つ形に寄せていきたい。そのときに「なぜ今は 1 を選んだか」がこの記事に残っていれば、移行の判断も早く済むはずです。