公開パイプライン検証
公開パイプラインは、どの実行ティアも運動させないテストレーンです -- 公開時にしか走らないライフサイクルフックはリリースがそこで失敗するまで腐り、CIと異なるツールチェーンで走るローカルヘビーレーンは構造上盲目です。プラットフォームバイナリのパッケージは、その上にさらに3つの転送レイヤの失敗モードを重ねます。
公開は、取り消しの効かない唯一のパイプライン段階です。赤いチェックも、失敗したデプロイも、壊れたビルドも、いずれもやり直せます。しかしnpmに押し上げられたバージョンは永続的で、途中で死んだ複数パッケージのリリースは、再実行では完全に修復できない状態をレジストリに残します。だからこそ、公開パイプラインはそれ自体を1つのレーンとして検証する価値があります。
このページはそれを2部構成で扱います。前半はパッケージの形に依存せず、何かを公開するあらゆるリポジトリに当てはまります:公開時にしか走らないゲートと、それを手元のヘビーレーンから隠してしまうツールチェーンのギャップです。後半はプラットフォームバイナリを出荷するパッケージという特殊ケースで、そこではpackと動くインストールのあいだに3つの転送レイヤの失敗モードが棲みついています。
公開時だけ走るゲートはどのティアも実行しないレーン
実行ティアモデルは、テストが走りうる5つの場所を扱います:ローカルの高速パス(T0)、PRゲート(T1)、分離されたe2eスイート(T2)、スケジュール再試験(T3)、そしてローカルヘビーレーン(T4)です。npm publishのときにしか走らないライフサイクルフックは、その5つすべての外側にあります。prepack、prepublishOnly、そしてその仲間たちは、リリース経路の上でだけ発火し、他のどこでも発火しません。だから、リリースとリリースのあいだ、それらを運動させるものは何もありません。それらは静かに腐り、そして腐りは、それが最も高くつく瞬間に表面化します。
この形は見落としやすいものです。フックがゲートのように見えるからです。あるリポジトリでは、パッケージのprepackが15個のcheck-*.mjs契約ガードとコード生成のドリフトチェックを連ねていました -- どう測っても本格的な検証スイートです。すべてのワークフローファイルとローカルヘビーレーンをその15個のスクリプト名でgrepした結果は、ヒット0件でした。それらの初めての実行は、自律的なリリーススクリプトが駆動する3パッケージのロックステップ公開の中でした:v5.6.1は途中で死に、1つのパッケージはすでにnpm上にあり、残る2つは取り残されました。その位置で腐ったチェックが赤くするのは、PRではなくリリースです。
Warning
「packやpublishのチェックを足そう」は、npmライブラリのリポジトリに対する定石であり、良い助言です。その助言が語らないのは、いま足したそのチェックが、何も実行しない唯一のレーンに住むことになるという点です。一度も実行されたことのないゲートは、ゲートではありません -- 唯一のトリガーが次のリリースであるような、テストされていないスクリプトです。
定義は1つ、呼び出し口は2つ
ルールはこうです:公開時にしか走らないライフサイクルフックは、同じスクリプトを別の名前で呼び出すCIジョブを必ず持たなければならない。 フックのコマンドをワークフローにコピーしてはいけません -- それは2つ目の定義であり、1つ目から自由に乖離していきます。フックの中身を1つの名前付きスクリプトに抽出し、フックはそこへ委譲させ、PRジョブからは同じスクリプトを呼びます。
{
"scripts": {
"check:prepack-contract": "node scripts/check-prepack-contract.mjs",
"prepack": "pnpm run check:prepack-contract"
}
}これでリリース経路とPRゲートは、1つの定義に対する2つの呼び出し口になりました。check-prepack-contract.mjsに足したガードは、次のリリースではなく次のPRで運動させられ、フックがCIの走らせないステップを黙って獲得することもできなくなります。
本物のライフサイクル経路をすべてのPRで走らせる
名前付きスクリプトを呼ぶことは、そのスクリプトが動くことを証明します。フックが動くことは証明しません -- prepackというキーのタイポ、発火するフックの集合が異なるパッケージマネージャ、スクリプトを含めていないfilesフィールド、どれもリリース経路を壊す一方で、直接呼び出しは緑のままです。実際のライフサイクル経路が実行されるよう、CIでpackを走らせます:
# .github/workflows/pr.yml (excerpt) -- the publish-only gates, pulled into T1.
jobs:
publish-gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# One declaration of the toolchain major, read by CI and local dev alike.
node-version-file: .nvmrc
- run: pnpm install --frozen-lockfile
# 1. The named script the prepack hook delegates to -- the direct call.
- run: pnpm run check:prepack-contract
# 2. The real lifecycle path: `pack` fires `prepack`, so a green here
# proves the hook itself, not a re-implementation of it.
- run: pnpm pack --dry-run
# 3. `prepublishOnly` is NOT fired by pack -- its commands are mapped to
# CI steps explicitly. Keep these in sync with the hook.
- run: pnpm build
- run: pnpm testpnpm pack --dry-run(あるいはnpm pack --dry-run)は、レジストリが受け取ることになるtarballを組み立てて、捨てます。コストは数秒、レジストリの資格情報は不要で、そしてprepackを本物として発火させます。
prepublishOnlyはpackでは発火しない
2つのフックは対称ではなく、この非対称性こそ見落とされる部分です:
| フック | packで発火 | publishで発火 | PRジョブでどう覆うか |
|---|---|---|---|
prepack | する | する | CIでのpack --dry-runが本物として走らせる |
prepublishOnly | しない | する | 到達するライフサイクル経路がない -- コマンドを手でCIステップに対応づけ、その対応を書き残す |
したがって、packベースのPRジョブが覆うのはprepackだけです。prepublishOnlyがpnpm build && pnpm testなら、PRゲートはおそらく両方をすでに走らせているでしょう -- そして、その「おそらく」こそが失敗そのものです。フックの代役を務めるステップ(上の3番)の隣にコメントとして対応関係をワークフローへ書き込み、次にprepublishOnlyへコマンドを足す人が、そのCI側の対応物をどこに置くべきか見えるようにします。
ローカルヘビーレーンはCIのランタイムで走らなければならない
同じ事故のもう一方の半分が、なぜ誰もこれを予見できなかったのかを説明します。当該リポジトリはCIの21箇所でnode-version: 22をピン留めする一方、ローカルのNodeバージョンはどこにも宣言していませんでした -- .nvmrcも、.node-versionも、ルートのenginesもなし。ローカル開発はNode 24とnpm 11で走っていました。リリースを殺した挙動は、npm 11で修正済みのnpm 10のバグでした。
つまりT4のローカルヘビーレーンは、後にリリース経路で失敗することになるのとまったく同じスイートを走らせ、そして通っていたのです -- 運が良かったからではなく、構造上そうなるからです。それは、そのバグが存在しないツールチェーンを実行していました。ローカルで何回再実行しようとこれを表面化させることはできず、同じ盲目さは、他のランタイム依存な事柄すべてに及びます。
Danger
CIと異なるメジャーのツールチェーンで走るローカルヘビーレーンは、ランタイム依存の挙動に対して構造上盲目です。それはコードを証明するのであって、環境を証明しません。Node 24上の緑のレーンは、Node 22にピン留めされたCIのレグについて何も語らず、そのギャップは両側から見えません:どちらのレーンも緑で、どちらもその緑をどのランタイムが生んだのかを報告しないからです。
構造的な直し方は安上がりです -- バージョンを一度だけ宣言し、両側にそれを読ませます:
# Before: every workflow pins its own number, and nothing declares the local one.
- uses: actions/setup-node@v4
with:
node-version: 22
# After: .nvmrc at the repo root holds the major; nvm and setup-node both read
# it, so the two sides cannot silently diverge.
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrcこれを見た目の問題ではなく構造の問題にしているのは、その数です。21箇所のピン留めがあると、数字を手で揃え続けるのは負け戦であり、新しく足されたワークフローは黙ってその慣習から外れます。node-version-fileなら、新しいワークフローが乖離を持ち込むことはできず、メジャーの引き上げは、CIとローカル開発を一緒に動かす1回の編集で済みます。
Note
これは重いテストの判断ルールにあるバーンインが証明するのは決定性であってCIでの実行可能性ではないと隣接しつつ、別物です。あちらはテストのフレイクさが環境によって異なるという話です -- 手元での繰り返しは、そのテストがCI上で安定していることを証明できません。こちらはツールチェーンそのものが異なるという話です:テスト対象のコードが別の経路を通るのであり、繰り返しをいくら重ねても、手元のランタイムが通る経路は変わりません。
プラットフォームバイナリという特殊ケース
esbuild流に出荷されたCLI -- ユーザーがインストールする1つのメタパッケージと、optionalDependencies経由で解決される一連のプラットフォーム別バイナリパッケージ -- には、PRゲートも夜間のレジストリドリフトネットも見られない検証サーフェスがあります:公開パイプラインそのものです。PRゲートはlockfileからインストールするため、生きたレジストリに対して解決を行いません。夜間ドリフトネットはレジストリに対して解決を行いますが、1日1回、1つのプラットフォーム上で走り、そしてインストールとビルドを運動させるだけです。両者の間には検証されないギャップ -- pack、publish、CDNの伝播 -- が横たわり、そこに3つの異なる失敗モードが棲みつきます。それぞれが静かで、それぞれが特定のプロジェクトの性質ではなく、npmレジストリとクライアントの性質です。
このページの残りは、その3つの失敗モードと、それぞれを塞ぐ公開時ゲートを解説します。パターンは標準的なもの(esbuild、swc、Biome、Turborepoはいずれもこの方式でバイナリを出荷します)なので、これらのゲートはnpm経由でプラットフォームバイナリを配布するあらゆるプロジェクトに一般化できます。
パッケージの形
このレイアウトは、ユーザーがインストールする単一のメタパッケージ(my-cli)に加えて、ターゲットトリプルごとに1つのパッケージ(my-cli-linux-x64-gnu、my-cli-darwin-arm64、my-cli-win32-x64-msvcなど)で構成されます。各プラットフォームパッケージは、そのトリプル向けにビルド済みのバイナリだけを持ちます。メタパッケージはすべてのプラットフォームパッケージをoptionalDependenciesに列挙し、インストール時にnpmはos/cpuフィールドが現在のマシンに一致するエントリだけを解決します -- そのため、LinuxユーザーはLinuxバイナリだけをダウンロードし、それ以外は何もダウンロードしません。
optional(任意) という語が、まさに問題そのものです。optionalDependencyに対するnpmのセマンティクスは「できるならインストールする;何か問題が起きたらスキップして成功終了する」です。それは、パッケージが本当にこのプラットフォームに該当しない場合にはまさに望ましい挙動であり -- そして、あらゆる配布バグを静かなものに変えてしまう、まさにその挙動でもあります。ダウンロードに失敗したtarball、誤ったモードで出荷されたバイナリ、中途半端に公開されたバージョン -- これらのどれもnpm installを失敗させません。すべてがバイナリを静かに欠落させたり使用不能にしたまま0で終了させ、失敗は後になって、それを引き起こしたパイプラインから遠く離れた、ユーザーの最初の実行時に表面化します。
Warning
「optional = 失敗時にスキップ可能」ということは、インストールステップは決してゲートになり得ないという意味です。メタパッケージに対する緑のnpm installは、メタデータが解決したことを証明するのであって、バイナリが到着して動くことを証明するのではありません。以下のゲートがすべて存在するのは、真っ先に手を伸ばしたくなる1つのシグナル -- インストールの終了コード -- が、これらのバグに対して構造的に盲目だからです。
失敗モード1:伝播していないtarballでの静かなスキップ
レジストリのメタデータは、大きなバイナリtarballよりも速くCDNを通じて伝播します。プラットフォーム別のtarballが約78 MBあるプロジェクトでは、バージョンメタデータは公開から数秒で解決可能になる一方、tarball自体はまだCDNエッジ間で複製中です。その窓の中では、optionalDependencyを解決した後にtarballの取得に失敗したクライアントは、npmの「optional = スキップ可能」パスに落ちます:npm installは0で終了し、バイナリは単に欠落し、ユーザーの最初の実行が実行時に失敗します -- パイプライン側からは緑に見える壊れたリリースです。
メタパッケージをインストールするだけの公開後スモークテストは、tarballが実際に取得可能になるまでここでは何も証明せず、たとえ取得可能になってもランナー自身のプラットフォームしか証明しません。ゲートは、リリースを良好と宣言する前に、すべてのプラットフォームパッケージについて実際のtarball取得を強制することです:
npm pack --dry-run <platform-pkg>@<dist-tag>は、単なるメタデータ解決ではなく本物のtarballダウンロードを強制するため、tarballが伝播するまで失敗します。これを、スモークランナーのアーキだけでなくすべてのプラットフォームパッケージについて、バックオフ付きでポーリングし、公開ジョブとクリーンルームスモークインストールの間に配置します:
# .github/workflows/release.yml (excerpt) -- runs AFTER the publish job and
# BEFORE the clean-room smoke install, gating on real tarball fetchability.
on:
workflow_dispatch:
inputs:
dist_tag:
description: "npm dist-tag to verify and smoke-test (e.g. latest, next)"
required: true
type: string
workflow_call:
inputs:
dist_tag:
required: true
type: string
jobs:
publish:
# ...publishes every platform package + the meta package under dist-tag
# ${{ inputs.dist_tag }} (see the workflow_dispatch/workflow_call inputs above)
wait-for-propagation:
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
# Probe EVERY platform package, not just the smoke runner's arch.
# `npm pack --dry-run` forces a real tarball fetch -- metadata resolving
# is not enough; the ~78 MB tarball itself must be downloadable.
- name: Wait for every platform tarball to propagate
run: |
# ${{ inputs.dist_tag }} is this workflow's own workflow_dispatch/
# workflow_call input (declared in the `on:` block above) -- the
# same tag the publish job just pushed to the registry.
TAG="${{ inputs.dist_tag }}"
for pkg in \
my-cli-linux-x64-gnu \
my-cli-darwin-x64 \
my-cli-darwin-arm64 \
my-cli-win32-x64-msvc; do
for attempt in $(seq 1 30); do
if npm pack --dry-run "${pkg}@${TAG}" >/dev/null 2>&1; then
echo "ok: ${pkg} tarball is fetchable"
break
fi
if [[ "$attempt" == 30 ]]; then
echo "::error::${pkg}@${TAG} tarball never propagated after 30 attempts"
exit 1
fi
sleep $(( attempt < 10 ? attempt * 5 : 60 ))
done
done
smoke-clean-room:
needs: wait-for-propagation
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# ...npx create-my-cli@<tag> in a temp dir, build, assert outputDanger
ランナー自身のアーキだけをプローブするのは、直っているように見えて直っていない罠です。ubuntu-latest上のリリースジョブがmy-cli-linux-x64-gnuだけをプローブすると、darwinとwin32のユーザーには毎回検証されていないtarballが出荷されます -- 彼らの伝播窓は一度も観測されません。ゲートは、公開するすべてのプラットフォームトリプルをループしなければなりません。さもなければ、それは全プラットフォーム向けゲートの衣装をまとった1プラットフォーム向けゲートにすぎません。
失敗モード2:packが実行ビットを剥ぎ取る
pnpm 9.xで観測された挙動です -- ピン留めしているバージョンに対して再検証してください。これはnpmエコシステムの文書化された不変の保証ではなく、pnpm自身のパッキング挙動だからです:pnpm packはファイルモードを0644に正規化し、出荷されるバイナリから0755の実行ビットを静かに剥ぎ取ります。インストールは成功し、ファイルはディスクに着地し、そしてそれを起動しようとするとEACCESで失敗します -- またしても緑のパイプライン、またしても実行時のみの失敗です。ここには2つのゲートがあり、それらは協調して働きます。
第一に、プラットフォームパッケージはnpm publishで公開すること。これはソースのファイルモードを保持します。pnpmは、そのworkspace:*書き換えを実際に必要とするパッケージのために取っておきます。プラットフォームパッケージはビルド済みバイナリを1つ持つだけでworkspaceプロトコル依存を持たないため、pnpm publishから得るものは何もなく、それに実行ビットを失うだけです。
第二に、公開する前にパックされたtarball内のモードをアサートし、その制約が、最後にリリーススクリプトを触った人の記憶ではなくCIによって強制されるようにします:
# CI gate: binary must be 0755 (493 decimal) inside the tarball
MODE=$(cd "$pkg_dir" && npm pack --dry-run --json 2>/dev/null \
| jq -r --arg b "$BIN_NAME" '.[] | .files[] | select(.path == $b) | .mode')
if [[ "$MODE" != "493" ]]; then
echo "::error::${pkg_dir}: binary mode is '${MODE}', expected 493 (0755). Was pnpm publish used instead of npm publish?"
exit 1
fi493は0755を10進で表したもの -- npm pack --jsonが報告するモードです。このゲートは、Windows向けのものを除くすべてのプラットフォームパッケージに対して実行します:実行ビットはWindowsでは意味を持たず、tarはそこで異なる値を報告することがあるため、win32パッケージに493をアサートすると、問題でないもののためにゲートが失敗してしまいます。
Tip
公開前アサーションの価値は、それが将来のリグレッションに対して大声で失敗することにあります。もし誰かが後になってリリーススクリプトをpnpm publishに戻したら -- 一貫性のためであれ、コピペであれ -- モードゲートは何もレジストリに届く前に赤くなり、エラーメッセージが正確な原因を名指しします。メンテナの頭の中にしか存在しない慣習は、リファクタリング1回で失われます。CIゲートはそうではありません。
失敗モード3:ダブルPUTのACKロストレース
npmのpublishはHTTP PUTであり、ネットワークの瞬断は、レジストリがすでにバージョンを保存した後に成功ACKを失わせることがあります。クライアントは、確認応答が得られないのを見てPUTをリトライします -- そしてそのリトライは403 ... cannot publish over the previously published versions(EPUBLISHCONFLICT)を受け取ります。publishは成功していたのに、失敗したように見えるだけなのです。set -eの下では、複数パッケージのリリースはリスト途中で死にます -- 9個中7個のパッケージを公開し、8個目でこの幻のコンフリクトに当たり、ジョブが中断します。さらに悪いことに、それは単純に再実行できません:すでに公開済みのパッケージは2回目の試行でどれも403を返すため、リスト全体を素朴にリトライすると即座に失敗します。
公開スクリプト内の2つのゲートがこれを直し、どちらもパッケージごとに置くべきものです:
冪等(idempotent)。 パッケージを公開する前に、
npm view <name>@<version>がすでに解決するかどうかをチェックし、解決するならスキップします。これにより、途中まで完了したリリースが最後まで再実行可能になります -- 2回目の実行はすでに着地したパッケージを通り過ぎ、残りを終わらせます。コンフリクト耐性(conflict-tolerant)。 公開が失敗したとき、特定のコンフリクトエラーテキストを照合し(加えて、そのバージョンが本当に上がっているかを確認するための、失敗後のバックオフ付きレジストリ再チェック)、その1つのケースだけをスキップとして許容します。それ以外のすべて -- 認証エラー、バリデーションエラー、本物のハードなネットワーク障害 -- では厳格に失敗します。裸の
403/E403を照合するのは広すぎます。本物の権限失敗を飲み込んでしまいます。
is_publish_conflict() {
printf '%s' "$1" | grep -qiE 'cannot publish over the previously published versions|EPUBLISHCONFLICT'
}
# per package: precheck registry → publish → on failure, is_publish_conflict OR
# registry recheck (N attempts, backoff) → tolerate as skip; anything else → failリスト途中のコンフリクトが残りのリリースを中断させることが決してないよう、1回の呼び出しにつき1パッケージを公開します。冪等な事前チェックと組み合わせることで、公開全体が、単一の幻の403で立ち往生しかねない1つの脆いシーケンスではなく、独立して再実行可能なステップの集合になります。
Warning
これはテストではありません -- 公開スクリプトの堅牢性という性質です。夜間ドリフトネットはレジストリに対してインストールとビルドを運動させますが、ACKロストレースを観測することは決してできません。なぜならそのレースは、まだ何もインストール可能になる前の公開ステップの内部で起きるからです。どれほど消費者側でテストしても、そこには届きません。ゲートは公開する側に置かなければなりません。
これらがティアモデルのどこに収まるか
このページの前半と後半では、答えが違います。その違いは、はっきり述べておく価値があります。
公開時だけ走るライフサイクルゲートにティアがないのは、それが間違った場所にあるからです。そして手当ては、そのためのティアを発明することではなく、それを動かすことです。check:prepack-contractがすべてのPRで走るようになれば、それはリリース時にも発火する普通のT1チェックになります -- 「公開時だけ」ではなくなり、ティアのないレーンは消えます。
上で見た3つの転送レイヤの失敗モードは、そのようには動かせません。tarballの伝播も、パックされたアーカイブ内のファイルモードも、失われた公開ACKも、観測すべき本物のリリースが存在するより前には存在しないため、これらはその場に留まります。これらはリリースパイプラインゲートです:公開のたびに1回実行され、バグがさもなくば出荷されるまさにその瞬間に、警告するか、ブロックします。そのため、これらは夜間のレジストリドリフトネットとは別種のものです。ドリフトネットはスケジュールで、1つのプラットフォーム上で走り、インストールとビルドを運動させます。ドリフトネットはこれらのゲートのスケジュール版のいとこです -- それは最終的に、1つのアーキ上でレジストリ側のリグレッションを捕まえます。これらのゲートは公開側のリグレッションを、即座に、すべてのプラットフォームにわたって捕まえます。失敗モード3はこの境界を具体的にします:ドリフトネットはACKロストレースをそもそも見ることが構造的にできません。なぜならその失敗は、インストールできる何かが存在するより前の、公開ステップの内部で起きるからです。
公開後のクリーンルームスモーク -- 真新しい一時ディレクトリでのnpx create-my-cli@<tag>、スキャフォールドのビルド、出力のアサート -- は、さらに3つ目の位置に収まります。それはゲートではなく警告ガードです:それが走る頃には公開はすでに起きているため、それは不良リリースを防ぐことはできず、すでに出荷されたものを検知できるだけです。その失敗を、ドリフトネットが使うのと同じワークフローごとに1つのトラッキングIssueパターンに配線し、壊れたリリースがちょうど1つのトラッキングIssueを開き、次のリリースが緑になったらそれをクローズするようにします。
Info
3つの位置を時間軸上の並びとして読んでください。公開前ゲート(モード2、モード3)はレジストリに触れる前にブロックします。伝播ポール(モード1)は公開とスモークインストールの間でブロックします。クリーンルームスモークはリリースが生きた後に警告します。それぞれが他のゲートには覆えない窓をカバーし、そのどれもインストールの終了コードではありません -- このパッケージの形にとって、それは3つすべてに対して盲目なのですから。