はじめに
CLIに「Deployment complete」と表示されても、利用者が見る本番が正しいとは限りません。CDNの古いキャッシュ、別projectへの誤deploy、画像だけ欠落、SPAフォールバックによる偽200、後処理失敗があり得ます。
公開完了の条件は、deployコマンドの終了コードではなく、本番URLから読み戻した内容が候補releaseと一致することです。これをreadbackと呼びます。対象ファイルのhash、固有見出し、canonical URL、Content-Typeをmanifestと照合し、一致した時だけpublishedへ進めます。
この実装ではdry-runでmanifestを固定し、route_idを含むschema、デプロイログ、HTTP readbackの設計を一つの検証経路にまとめます。
release manifestを作る
deploy前に、公開したいファイルだけを列挙したmanifestを固定します。ディレクトリ全体を曖昧にhash化せず、URLとの対応を明示します。
type ManifestEntry = {
localPath: string;
publicUrl: string;
sha256: string;
contentType: string;
requiredText?: string[];
};
type ReleaseManifest = {
releaseId: string;
deploymentTarget: string;
createdAt: string;
entries: ManifestEntry[];
};
HTMLはビルド時刻やnonceで毎回変わる場合があります。その場合、バイト列全体ではなく、比較対象部分を正規化するか、data-release-id、固有H1、記事リンクなど意味のある要素を照合します。画像やJSONはバイトhashを使えます。
HTMLを意味単位で正規化する
HTMLの空白や属性順だけで不一致にしないため、DOMから必要部分を抽出します。逆に、単なる文字列 includes だけでは別ページのフッターに同じ文字があっても合格します。
import { load } from "cheerio";
import { createHash } from "node:crypto";
function semanticHtmlHash(html: string): string {
const $ = load(html);
$("script[data-runtime], meta[name=build-time]").remove();
const normalized = {
title: $("title").text().trim(),
h1: $("h1").first().text().replace(/\s+/g, " ").trim(),
canonical: $("link[rel=canonical]").attr("href") ?? "",
main: $("main").text().replace(/\s+/g, " ").trim(),
};
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}
候補release側も同じ関数でhash化します。比較関数が違うと、ローカルと本番が同じでも一致しません。正規化ロジック自体をversion管理し、Receiptへ hash_method を残します。
SPAの偽200を止める
存在しない画像URLにアクセスしてHTTP 200が返り、実体はトップページHTMLという構成があります。画像の確認ではstatusだけでなくContent-Typeとマジックバイトを見ます。
from urllib.request import Request, urlopen
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def verify_png(url: str) -> None:
req = Request(url, headers={"User-Agent": "release-readback/1.0"})
with urlopen(req, timeout=20) as res:
content_type = (res.headers.get("Content-Type") or "").lower()
prefix = res.read(8)
if "image/png" not in content_type or prefix != PNG_SIGNATURE:
raise RuntimeError(f"png_readback_failed:{content_type}")
同様にJSONはContent-Typeとparse成功、HTMLは固有H1とcanonicalを確認します。リダイレクト後の最終URLも記録し、別ホストへ飛んでいないか検証します。
キャッシュを考慮して再確認する
CDN反映には時間差があります。ただし無制限に待つと障害通知が遅れます。短いreadback retryを設定し、各回でETag、Age、CF-Cache-Statusなど観測可能なヘッダーを記録します。
async function verifyWithBudget(check: () => Promise<void>) {
const waits = [0, 3_000, 7_000, 15_000];
let lastError: unknown;
for (const wait of waits) {
if (wait) await new Promise((resolve) => setTimeout(resolve, wait));
try {
await check();
return;
} catch (error) {
lastError = error;
}
}
throw new Error(`readback_exhausted:${String(lastError)}`);
}
readback失敗時に新規deployを始めてはいけません。deployment IDをReceiptに保持し、同じdeploymentの反映を再確認します。別IDで成功させると、どのreleaseが本番か追跡しにくくなります。
トップページと詳細ページを分ける
記事公開では、詳細ページだけでなくトップの最新記事カードも更新されます。確認対象を「記事URL」「トップの見出し・日付・リンク」「OGP」「PC/SP表示」に分けます。
本文が正しくてもトップのリンクが旧URLなら回遊が壊れます。逆にトップだけ更新され、詳細が404の場合もあります。manifestへ両方を入れ、それぞれ固有の期待値を持たせます。
PC/SPは同じHTMLでもCSSで欠けるため、Playwrightで1440pxと390pxを開き、overflow、console error、リンク遷移を確認します。スクリーンショットhashを合否の唯一条件にはせず、目視証跡として保存します。
負試験を用意する
readbackは正常系より失敗系が重要です。偽200、旧HTML、画像Content-Type不正、別release ID、タイムアウトを再現します。
import { expect, test } from "vitest";
test("旧H1ならpublishedにしない", async () => {
const live = "<html><main><h1>旧記事</h1></main></html>";
expect(() => assertSemanticMatch(live, expectedManifest)).toThrow("h1_mismatch");
});
test("PNG URLがHTMLなら失敗", async () => {
mockFetch.mockResolvedValue(new Response("<html>fallback</html>", {
status: 200,
headers: { "content-type": "text/html" },
}));
await expect(readbackPng("https://example.test/a.png")).rejects.toThrow();
});
テスト完了条件は、失敗時にpublishedが増えないこと、候補releaseがpreparedで残ること、最終通知が1回だけであることです。
まとめ
deploy成功と公開成功は別の状態です。release manifestを固定し、HTMLは意味単位、画像はContent-Typeと署名、詳細とトップは別URLとしてreadbackします。
この運用なら、実行環境やAIモデルが変わっても「何を公開したかったか」「本番に何が出たか」をhashで引き継げます。外部ツールとつながるAIパートナーほど、操作結果を記憶へ戻すreadbackが不可欠です。

