0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

デプロイ後にHash照合するReadback設計

0
Posted at

デプロイ後にHash照合するReadback設計

ローカルmanifestと本番readbackをhash照合する公開確定フロー

はじめに

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が不可欠です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?