きっかけは、自社サイトの依存パッケージを「そろそろ上げよう」と思って npm outdated を打ったことでした。
22 件出てきました。astro は 6.0.8 で、範囲内の最新(Wanted)が 6.4.8、最新(Latest)は 7.3.2。Tailwind CSS も 4.2.2 から 4.3.3。どれもメジャーは変えず、package.json に書いてある ^6.0.8 や ^4.2.2 の範囲に収まる更新です。semver の建前で言えば「後方互換の機能追加とバグ修正だけ」の領域で、npm update を一発叩けば package.json は 1 文字も変わらず、package-lock.json だけが書き換わる。
ここで手が止まりました。うちの CI は型チェック(astro check)とビルドの 2 段で、どちらも「壊れて落ちるもの」しか見ていません。更新のあとに「出力が変わっていないこと」を確かめる手段が、正直なところ何もなかった。動いた=品質OK ではない、と普段から言っている以上、まず測る側を作ってから更新することにしました。
結論から書くと、astro を 6.0.8 から 6.4.8 に上げただけで、13 ページの表が | 列 | 列 | という素のテキストに退化しました。ビルドは終了コード 0、ページ数は 112 で変わらず、型チェックも通ります。何もない状態で npm update していたら、そのままプレビューに出て、たぶんマージされていたと思います。
更新の受け入れ基準が「ビルドが通ること」しかなかった
品質は工程で作り込む、と私はよく言うのですが、依存更新という工程には基準がありませんでした。あるのは「CI が緑」だけ。CI が何を保証しているかを言葉にすると、こうなります。
astro checkが 0 error: 型と Frontmatter のスキーマが正しいastro buildが exit 0: 変換の途中で例外が出なかった
どちらも「本文がどう出力されたか」は見ていません。表が表として出ているか、リンクがリンクになっているか、引用符が意図したものかは、人間がプレビューを目で見る以外に確かめる手段がない。76 記事に対してそれを毎回やるのは無理です。
テストは目的ではなく手段です。今回の目的は「更新の前後で出力が変わっていないと機械が言える状態」を作ること。そのための手段として、現状の出力そのものを仕様と見なす特性化テスト(ゴールデンマスター)を選びました。仕様書から期待値を書く余裕がない場面で、いま動いているものを正解として固定し、変化だけを検出する手法です。
ゴールデンにできるかは、まず決定性を測ってから
特性化テストの前提は「同じ入力から同じ出力が出ること」です。ここを確かめずにゴールデンを取ると、更新と関係ない差分に埋もれて何も読めなくなる。なので最初にやったのは、同じコミットを 2 回ビルドして dist 配下の全ファイルを比べることでした。
# 同じコミットから 2 回ビルドし、dist の全ファイルを sha256 で固定する
npx astro build && (cd dist && find . -type f | sort | xargs sha256sum) > golden.sha
npx astro build && (cd dist && find . -type f | sort | xargs sha256sum) > second.sha
diff golden.sha second.sha | grep -c '^[<>]'
# 実行結果: 0 (274 ファイル、差分なし)
274 ファイル、差分 0。タイムスタンプも乱数も混ざっていない。Astro の静的ビルドはこのリポジトリの範囲では完全に決定的でした。ここが 0 でなければ、まず非決定な箇所を潰すか、その箇所だけ正規化する必要があります。今回は不要でした。
これで golden.sha 1 ファイル(31 KB)が仕様になります。更新後に同じ手順でハッシュを取り、diff が空なら「出力は変わっていない」と言える。逆に空でなければ、変わった理由を 1 件ずつ説明できるまでマージしない。基準はこれだけです。
最初の実験は「112 ページ全部が変わった」で読めなかった
astro だけを 6.4.8 に上げてビルドし、diff を取ったら、HTML 112 ファイルすべてがハッシュ不一致になりました。
今思えば当たり前なんですが、原因は本文ではなくファイル名でした。Astro が吐く CSS のファイル名が index@_@astro.Jwy17ZZs.css から index.Jwy17ZZs.css に変わっていて、中身のハッシュ(Jwy17ZZs)は同じ。つまり CSS の中身は 1 バイトも変わっていないのに、それを参照する <link> の href が全ページで変わった。加えて </style> と <link> の間にあった改行が消えていました。この 2 つで 112/112 です。
「全部違う」は「何も分からない」と同じです。ゴールデンマスターは差分をそのまま出すだけでは機能しなくて、意味のない差分を正規化して、意味のある差分だけを残す工程が要ります。ファイル名の @_@astro を消し、<link> 前後の改行を詰めてから比べ直すと、85 ページが一致し、27 ページに実質的な差分が残りました。その 27 を機械で分類したのが次のスクリプトです。
# 正規化してから比較し、残った差分を種類別に数える(抜粋)
import difflib, re
def normalize(html: str) -> str:
html = html.replace("@_@astro", "") # CSS ファイル名の変更を吸収
html = re.sub(r'\n(?=<link rel="stylesheet")', "", html) # link 前の改行
return re.sub(r'(<link rel="stylesheet"[^>]*>)\n(?=<style>)', r"\1", html)
def classify(before: str, after: str) -> set[str]:
kinds = set()
sm = difflib.SequenceMatcher(None, before, after, autojunk=False)
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op == "equal":
continue
lost, got = before[i1:i2], after[j1:j2]
if "<table" in lost and "| " in got:
kinds.add("TABLE_TO_TEXT") # 表が素のテキストに退化
elif "contains-task-list" in lost:
kinds.add("TASKLIST_LOST")
elif "<a href=" in lost and "<a" not in got:
kinds.add("AUTOLINK_LOST")
elif "&" in lost and "&" in got:
kinds.add("ENTITY_STYLE") # 表記だけの違い。受け入れる
else:
kinds.add("PUNCTUATION") # 引用符・ダッシュの変化
return kinds
分類の結果はこうでした。
| 差分の種類 | ページ数 | 何が起きたか | 判定 |
|---|---|---|---|
| 表がテキストに退化 | 13 | Markdown の表が table 要素にならず、p 要素の中に縦棒区切りの文字列として出る | 意図しない。直す |
| タスクリストの消失 | 2 | チェックボックスが「[ ]」の文字に戻る(上の 13 に含まれるページ) | 意図しない。直す |
| 自動リンクの消失 | 1 | 本文中の裸の URL がリンクにならない | 意図しない。直す |
| 引用符・ダッシュの変化 | 8 | 曲がった引用符が直線に、全角ダッシュが「—」に戻る | 意図しない。直す |
| 実体参照の表記 | 5 | 数値参照の & が名前参照に変わる。表示は同一 | 意図した変更として受け入れる |
13 ページの内訳は、WordPress 復旧ガイドの 9 ページ全部と、テックブログの 4 記事です。復旧ガイドは「症状と原因の対応表」が本体なので、表が消えると読み物として成立しません。しかもこの 13 ページ、astro check は 0 error、ビルドは exit 0、ページ数 112 で不変。基準を成果物に置いていなければ、検知する手段が存在しませんでした。
原因は「既定値の置き場所」が動いたこと
表・タスクリスト・自動リンクは GFM(GitHub Flavored Markdown)の機能で、引用符とダッシュは smartypants の機能です。この 2 つがまとめて効かなくなっている。設定ファイルは何も触っていないので、既定値の解決が変わったと見当をつけて、実際に解決された設定値を出す小さなインテグレーションを差し込んで確かめました。
// astro.config.mjs に一時的に差し込んだ観測用インテグレーション(確認後に削除)
const probe = {
name: 'probe',
hooks: {
'astro:config:done': ({ config }) => {
console.log('gfm=', config.markdown.gfm, 'smartypants=', config.markdown.smartypants,
'processor=', config.markdown.processor?.name);
},
},
};
// astro 6.0.8 : gfm= true smartypants= true processor= undefined
// astro 6.4.8 : gfm= undefined smartypants= undefined processor= unified
6.0.8 では markdown.gfm と markdown.smartypants が true で解決されていました。6.4.8 ではどちらも undefined になり、代わりに markdown.processor が現れています。ソースの当該箇所を読むと、gfm と smartypants は非推奨になり「利用者が明示しない限り未定義のまま置き、実際の既定値はプロセッサ側が供給する」と書かれていました。Astro 本体が .md を処理する経路では、確かにプロセッサが GFM を有効にしています。
問題は MDX 側です。@astrojs/mdx は既定で extendMarkdownConfig: true、つまり Astro の markdown 設定をそのまま引き継ぎます。引き継いだ gfm が undefined なので、MDX の変換パイプラインに remark-gfm が積まれない。ドキュメントにはこう書いてあるけど、という話で言えば、MDX 統合のドキュメントは今も「GFM は既定で有効」と読めます。Astro 本体の既定値が「設定の値」から「プロセッサの振る舞い」へ移った結果、設定の値を見ていた統合だけが取り残された形です。@astrojs/mdx を 5.0.6 に上げても、smartypants は戻りましたが表は戻りませんでした。
直し方は 2 通り試しました。Astro 側で markdown: { gfm: true, smartypants: true } と明示すると 107/112 が一致しますが、ビルドのたびに非推奨の警告が出ます。非推奨の値に依存して直すのは、次の更新で同じことをもう一度やる約束をするのと同じなので採りませんでした。もう一つは MDX 統合の側で mdx({ gfm: true, smartypants: true }) と明示する方法で、これも 107/112 一致、警告なし。残った 5 ページは先ほどの表の「実体参照の表記」だけで、& が & に変わったもの。表示は同じなので、これは意図した変更としてゴールデンを更新しました。
全部まとめて上げたら、今度はビルド自体が落ちた
astro 単体で仕組みが分かったので、次に 22 件を全部 npm update しました。package-lock.json は 813 パッケージ中 266 が更新、96 追加、125 削除。そしてビルドが 606 ミリ秒で落ちました。
[@tailwindcss/vite:generate:build] Missing field `tsconfigPaths`
on BindingViteResolvePluginConfig.resolveOptions
file: src/styles/global.css
$ npm explain vite
vite@7.3.6 node_modules/astro/node_modules/vite
vite@"^7.3.2" from astro@6.4.8
vite@7.3.6 node_modules/@astrojs/react/node_modules/vite
vite@"^7.3.2" from @astrojs/react@5.0.7
vite@8.3.0 peer node_modules/vite
peer vite@"^5.2.0 || ^6 || ^7 || ^8" from @tailwindcss/vite@4.3.3
peer vite@"^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" from @vitejs/plugin-react@5.2.0
vite が 2 実体になっています。Astro 6 は vite@^7.3.2 を通常の依存として持っているので自分の配下に 7.3.6 を抱え、一方で Tailwind の Vite プラグインは peer 依存で ^8 まで許している。npm update はその peer を満たす最新として 8.3.0 をルートに置きました。Vite 8 はバンドラが Rolldown に切り替わった世代で、プラグインの内部インターフェースが 7 と違う。Tailwind 側のプラグインは 8 用に初期化され、それを Astro が動かす 7 の中で実行しようとして、tsconfigPaths というフィールドが無いと言われて止まった、というのが起きたことです。
ここは好みが分かれるところですが、私はこの挙動を peer 依存の設計と矛盾していると感じています。peer 依存は本来「ホストと同じ実体を 1 つだけ使う」ための仕組みで、範囲外なら ERESOLVE で止まるのが期待される振る舞いです。ところが今回は、すべての範囲を「満たして」いるので何のエラーも出ないまま、2 実体という一番避けたい状態に静かに到達しました。範囲が広い peer は安全側ではなく、更新のたびに最大メジャーへ引っ張られる力として働く。これは実際に踏むまで分かりませんでした。
対処は vite をこちらの devDependencies に ^7.3.2 として明示し、ルートの実体を 1 つに固定することです。手元では --no-save で 7.3.6 に揃えて再ビルドし、ゴールデンと比べました。今度は Tailwind 4.2.2 から 4.3.3 の差が出ます。global.css が 66,092 バイトから 68,943 バイトに増え、preflight の既定フォント指定が変わり、.shimmer や .tab-* のようなユーティリティが増え、.start と .end が消えました。消えた 2 つは HTML 側で一度も使われていないことをゴールデンの HTML 全件に対する grep で確認して、受け入れ。フォント 5 ファイルのハッシュが変わったのは @fontsource-variable/geist の更新で、こちらも受け入れ。HTML 本文の差分は astro 単体のときと同じ 5 ページ(実体参照)だけでした。
受け入れ基準に落とす
ここまでを、依存更新 PR の受け入れ基準として書き直したのが次の表です。「CI が緑」の 1 行だったものが 4 行になりました。
| 基準 | 確かめ方 | 今回どうだったか |
|---|---|---|
| 型チェックとビルドが通る | 従来の CI | astro 単体は通過。全件更新は vite 2 実体で失敗 |
| 正規化後のゴールデン差分がゼロ、または全件が分類済み | golden.sha との diff と分類スクリプト | 未対処だと 27 ページ。修正後は 5 ページ(実体参照のみ) |
| 意図した差分はゴールデンの更新を同じ PR に含める | golden.sha の変更行とレビューコメントの対応 | 実体参照 5 ページと CSS・フォントのハッシュ変更を明記 |
| ビルドツールの実体が 1 つ | npm explain vite の結果が 1 行 | devDependencies に vite を明示して固定 |
CI に載せる形はこうです。main のゴールデンをリポジトリに置き、PR のビルド結果と比べます。
# 依存更新 PR でだけ動かす想定。正規化と分類は scripts/ 配下に置く
- name: Build
run: npm run build
- name: Compare with golden
run: |
(cd dist && find . -type f | sort | xargs sha256sum) > current.sha
python scripts/normalize-and-classify.py golden.sha current.sha dist
# 未分類の差分が 1 件でもあれば非ゼロで終了し、PR を止める
正直なところ、ここは改善の余地があります。ゴールデンを main にコミットする方式だと、記事を 1 本足す普通の PR でも golden.sha が変わるので、毎回更新をコミットに含める運用になる。記事 PR と依存更新 PR が並走すると、どちらかが必ず古いゴールデンで比較することになります。代案は「PR のたびに main を別ディレクトリでもう一度ビルドして、その場でゴールデンを作る」方式で、コミット物が要らない代わりにビルド時間が 2 倍(うちだと 10 秒が 20 秒)になる。今は前者で始めて、並走の衝突が実際に起きたら後者に切り替える、という順番で考えています。
振り返って
今回いちばん効いたのは、更新の前に「同じ入力で同じ出力になるか」を 274 ファイルで確かめたことでした。あれが 0 だったから、その後の 112 も 27 も 5 も、全部「更新のせい」と言い切れた。測れないものは改善できない、の一番手前にある「測れる状態にする」工程を飛ばさなかったのが、結果的に近道だったんですよね。
一方で、semver の範囲内の更新を「安全な更新」と呼んでいた自分の感覚は改めます。package.json が変わらない更新でも、13 ページの表が消え、ビルドツールが 2 実体になった。範囲内かどうかは変更の大きさを表していなくて、単に「作者が互換だと宣言した範囲」でしかない。宣言を信じるのではなく、出力で確かめる。それができる手段が、今回はハッシュの一覧 1 ファイルでした。
今後は、この特性化テストを依存更新 PR の必須ゲートにして、ゴールデンの並走問題が出た時点で「その場で main を再ビルドする」方式へ移す予定です。あとは MDX 統合の gfm と smartypants を設定で明示する変更と、vite を直接依存に固定する変更を、更新本体とは別の小さな PR で先に入れておく。テストは目的ではなく手段なので、更新のたびにこの 4 行を人が読まなくて済むところまで持っていけたら、そこで一区切りだと思っています。