プラットフォームバイナリnpmパッケージの公開パイプライン検証
esbuild流のメタ + プラットフォーム別optionalDependenciesレイアウトのための公開時ゲート -- 伝播していないtarballでの静かなスキップ、剥ぎ取られた実行ビット、ダブルPUT公開レースという、夜間のレジストリドリフトネットが見られない3つの失敗モード。
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ロストレースを観測することは決してできません。なぜならそのレースは、まだ何もインストール可能になる前の公開ステップの内部で起きるからです。どれほど消費者側でテストしても、そこには届きません。ゲートは公開する側に置かなければなりません。
これらがティアモデルのどこに収まるか
これらはリリースパイプラインゲートです:公開のたびに1回実行され、バグがさもなくば出荷されるまさにその瞬間に、警告するか、ブロックします。そのため、これらは夜間のレジストリドリフトネットとは別種のものです。ドリフトネットはスケジュールで、1つのプラットフォーム上で走り、インストールとビルドを運動させます。ドリフトネットはこれらのゲートのスケジュール版のいとこです -- それは最終的に、1つのアーキ上でレジストリ側のリグレッションを捕まえます。これらのゲートは公開側のリグレッションを、即座に、すべてのプラットフォームにわたって捕まえます。失敗モード3はこの境界を具体的にします:ドリフトネットはACKロストレースをそもそも見ることが構造的にできません。なぜならその失敗は、インストールできる何かが存在するより前の、公開ステップの内部で起きるからです。
公開後のクリーンルームスモーク -- 真新しい一時ディレクトリでのnpx create-my-cli@<tag>、スキャフォールドのビルド、出力のアサート -- は、さらに3つ目の位置に収まります。それはゲートではなく警告ガードです:それが走る頃には公開はすでに起きているため、それは不良リリースを防ぐことはできず、すでに出荷されたものを検知できるだけです。その失敗を、ドリフトネットが使うのと同じワークフローごとに1つのトラッキングIssueパターンに配線し、壊れたリリースがちょうど1つのトラッキングIssueを開き、次のリリースが緑になったらそれをクローズするようにします。
Info
3つの位置を時間軸上の並びとして読んでください。公開前ゲート(モード2、モード3)はレジストリに触れる前にブロックします。伝播ポール(モード1)は公開とスモークインストールの間でブロックします。クリーンルームスモークはリリースが生きた後に警告します。それぞれが他のゲートには覆えない窓をカバーし、そのどれもインストールの終了コードではありません -- このパッケージの形にとって、それは3つすべてに対して盲目なのですから。