前回は Pagefind で作るサイト内検索の話をしましたが、今回はトラブルシュート系の番外編です。

このブログには「下書き機能」があります。記事の frontmatter に draft: true と書いておくと、開発中(pnpm dev)では一覧に表示されるのに、本番ビルドからは除外される、という仕組みです。書きかけの記事を安心して置いておけるので重宝していました。

ところがある日、本番ビルドの出力を眺めていて気づきました。まだ公開するつもりのない下書きが、しれっと本番に混ざっていたのです。

下書きを除外する仕組み

まず、当時の仕組みをおさらいします。記事の取得処理では、ビルド時かどうかを判定して下書きをフィルタしていました。判定の入り口はこんな感じです。

src/utils/content.ts
export function filterDrafts(
entries: BlogEntry[],
isBuildLikeEnv: boolean,
): BlogEntry[] {
return isBuildLikeEnv ? entries.filter((e) => !isDraftEntry(e)) : entries;
}
export async function getBlogEntries(): Promise<BlogEntry[]> {
const entries = await getCollection("blog");
return filterDrafts(entries, isBuildLike());
}

ロジック自体はシンプルで、「ビルド系の環境なら下書きを捨てる、そうでなければ全部返す」だけです。問題は、その「ビルド系かどうか」をどう判定していたか、でした。当初の判定はこうなっていました。

src/utils/env.ts(当時)
export function isBuildLike(): boolean {
return import.meta.env.PROD;
}

import.meta.env.PROD を見ているだけです。普通に考えれば、astro build のときは true になってくれるはずです。

問題発生:build しても下書きが消えない

実際に確認してみると、pnpm build で生成された dist/ の中に、draft: true のはずの記事ページが普通に出力されていました。pnpm preview でアクセスしても表示されてしまいます。

最初は「draft の値を読み間違えているのでは」と思い、isDraftEntry を疑いました。でも frontmatter は確かに draft: true。フィルタのロジックも、目で追う限り間違っていません。

そこで getBlogEntries の中に一時的なログを仕込んで、判定値そのものを出力してみました。

確認用のログ(一時的に追加)
console.log("PROD:", import.meta.env.PROD);
console.log("DEV :", import.meta.env.DEV);
console.log("MODE:", import.meta.env.MODE);

pnpm build で出てきた結果がこれです。

PROD: false
DEV : true
MODE: production

astro build を回しているのに PRODfalse。一方で MODE はちゃんと production になっています。ここで「あ、これは環境変数の話だ」とピンときました。

原因:シェルに NODE_ENV=development が固定されていた

僕の開発環境は Docker コンテナ内に構築しているのですが、そのコンテナに NODE_ENV=development が固定で入っていました。これが効いていたのです。

Vite の import.meta.env.PRODMODE は、似ているようで根っこが違います。

  • import.meta.env.MODE … 実行モード。astro build はデフォルトで production モードになる。コマンド側で決まる値。
  • import.meta.env.PROD … 本番稼働かどうかの真偽値。こちらは NODE_ENV === "production" を基準にしている。

つまり NODE_ENV=development が居座っていると、たとえ astro build(= MODEproduction)を実行しても、PRODNODE_ENV に引っ張られて false のままになります(Vite の環境変数の仕様は公式ドキュメントの Env Variables and Modes が詳しいです)。PROD だけを信じていた isBuildLike は、ビルド中なのに「これは開発環境だ」と判断し、下書きを素通しさせていた、というわけでした。

NODE_ENV はその気がなくても紛れ込みがちな変数です。コンテナのベースイメージ、Dev Container の設定、シェルの初期化スクリプト、CI のジョブ定義など、心当たりが複数あって特定に時間がかかりました。「環境によって挙動が変わる」系のバグは、まず変数の実値をそのまま出力するのが結局いちばん速いです。

解決策:MODE も併せて見る

原因がわかれば対処はシンプルです。NODE_ENV に依存する PROD だけに頼らず、コマンドで決まる MODE も併せて評価するようにしました。

src/utils/env.ts(修正後)
export function isBuildLike(): boolean {
return import.meta.env.PROD || import.meta.env.MODE === "production";
}

これで「PRODtrue」でも「MODEproduction」でも本番系と判定されます。NODE_ENV がどう設定されていようと、astro build であれば確実に下書きが除外されるようになりました。

判定を env.tsisBuildLike という一箇所に切り出しておいたのも、地味に効きました。下書き除外のロジックは記事一覧・タグページ・個別ページなど複数の場所から呼ばれているのですが、判定が一箇所にまとまっていたおかげで、直すのはこの関数だけで済んでいます。

テストで二度と踏まないように

同じ罠を未来の自分が踏まないよう、isBuildLike にはテストを足しました。特に「PRODfalse でも MODEproduction なら true」という、まさに今回ハマったケースを明示的に固定しています。

src/utils/env.test.ts
8 collapsed lines
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("isBuildLike", () => {
beforeEach(() => {
vi.resetModules();
});
it("PROD=true のときtrueを返す", async () => {
vi.stubEnv("PROD", true);
const { isBuildLike } = await import("./env");
expect(isBuildLike()).toBe(true);
vi.unstubAllEnvs();
});
it("PROD=false かつ MODE='development' のときfalseを返す", async () => {
vi.stubEnv("PROD", false);
vi.stubEnv("MODE", "development");
const { isBuildLike } = await import("./env");
expect(isBuildLike()).toBe(false);
vi.unstubAllEnvs();
});
it("PROD=false でも MODE='production' のときtrueを返す(NODE_ENV=development 下の astro build)", async () => {
vi.stubEnv("PROD", false);
vi.stubEnv("MODE", "production");
const { isBuildLike } = await import("./env");
expect(isBuildLike()).toBe(true);
vi.unstubAllEnvs();
});
});

vi.stubEnv を使うと import.meta.env の値を差し替えられるので、環境変数まわりのロジックも素直にテストできます。バグそのものをテストケースとして残しておくと、原因も再発防止も一目で伝わるのが気に入っています。

おまけ:あえて PROD だけを見ている場所もある

実はこのブログには、import.meta.env.PROD だけで判定している箇所がもう一つあります。Google Analytics の読み込みです。こちらは「ローカルの preview では計測タグを出したくない、実際にデプロイされた環境でだけ有効にしたい」という意図があり、NODE_ENV 依存の PROD がちょうど都合よく働いてくれます。同じ PROD でも、使う場所によって正解が変わるのが面白いところです。

「ちょっと下書きを置いておくだけ」のつもりの機能が、環境変数ひとつで本番に漏れる。判定の根っこにある値が何で決まっているかを知らないと、こういう取りこぼしは平気で起きるのだと痛感しました。同じところでつまずく人が一人でも減れば幸いです。