zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

定期再試験 & 夜間試験

重くプラットフォーム依存なテストレーンをスケジュール実行で運用する -- CI再試験ワークフロー、重複排除されたIssue起票、マージ前のオンデマンドディスパッチ、そしてエージェントトリアージ付きのローカル夜間試験。

ローカル専用の重いレーンがセーフティネットにならない理由

PRのCIでは決して実行できないテストレーンがあります:ハードウェアGPUを必要とするピクセルアサーション、実OSでしか信頼できないキーボードショートカットの配送など。テストがそのように分類される経緯は重いテストの判断ルールで扱います。このページは、分類されたの運用をどうするかを扱います。安直な答えは「プッシュ前に開発者のマシンでそのレーンを実行する」 -- ローカルの重いレーン、つまりティアT4です。利便性としてはそれで構いません。しかし唯一のセーフティネットとしては、3つの点で破綻します:

  • バイパス可能である。 ローカルゲートは、誰かが実行するかもしれないし、しないかもしれないスクリプトにすぎません。締切のプレッシャー下、借り物のマシン上、あるいは--no-verifyの陰で、それは静かに実行されなくなります。

  • マシン依存である。 キーボードショートカットのe2eスペックが実WebKit/macOS上でしか信頼できないTauri製テキストエディタアプリを考えてみてください:Linux/WSL2ホストでは同じスイートが何十ものスペックを偽の赤(false-red)にし、実macOSマシンこそがゴールドスタンダードです。ローカルゲートに意味があるかどうかすら、誰のマシンで実行されたかに依存します。

  • 記録(ペーパートレイル)が残らない。 「このレーンが最後に通ったのはいつ、どのハードウェア上で?」に誰も答えられません。ある1つのターミナルのスクロールバックの中にしか存在しなかった緑は、リグレッションゲートではありません。

Note

AIエージェントは「もう一人のコントリビュータ」です。 上の議論はかつてはチームメイトについてのものでしたが、いまではコーディングエージェントにもそのまま当てはまります。サンドボックス内、CI内、あるいはLinuxホスト上で作業するエージェントは、まさにあなたのmacOS専用レーンを実行できないコントリビュータであり、悪意なくローカルゲートを毎回バイパスします。正しい人が正しいマシンで手順を覚えていることを要求するセーフティネットは、ネットではありません。

解決策はローカルレーンを捨てることではなく、それに強制レイヤーの役割を求めるのをやめることです。速度と利便性のためにローカルレーンは残しつつ、スケジュールCI再試験を追加します:重いレーンを、能力のあるハードウェア上でスケジュール実行し、記録を残し、自動でIssueを起票するのです。

2つのコマンド、2つの予算:b4push と exam

スケジュールティアを作る前に、ローカルコマンドを分割します。プッシュ前の利便性パスと、全リグレッションの重い実行は別の仕事であり、別の名前と別の時間予算が必要です:

コマンド内容予算誰が実行するか
b4pushlintゲート、型チェック、影響範囲のユニットテスト、ビルド、CIセーフなスモーク上限付き、5--10分全員、毎プッシュ前、どのマシンでも
exam全リグレッションの重い実行:GPU、WebKit/macOS、長時間フロー上限なしオプトインかつプラットフォームゲート付き:スケジュールCI、または夜間の対応マシン

この分割は特定の失敗モードを防ぎます:1つのコマンドが両方の仕事を徐々に抱え込んでいくことです。プッシュ前パスが予算を超過した瞬間、人も -- エージェントも -- それをスキップし始め、プッシュ前には何も実行されなくなります。examが遅くてよいのは、誰もそれを待って座っていないからこそであり、b4pushが信頼され続けるのは、速いからこそです。

この名前は意図的なものです。すべてのプッシュが試験全体を受けられるふりをする代わりに、プロジェクトが定期的に試験(exam)全体を受け直すのです。

b4push が予算を超えたとき

b4pushが予算を徐々に超えてきたら、この順番でトリムします -- ゲートが「実際に機能している」状態を保つため、ステップごとの計測出力を必須にします:

  1. フルe2e → @smokeサブセット。 クリティカルなジャーニーと、現在のdiffが触れるエリアをカバーするスイートだけを残します。判断基準:フルe2e実行が1〜2分を超えるならスコープを絞る。それ以外は全部スケジュールexamのバックストップに委ねます。

  2. フルユニット実行 → 影響範囲のみ。 turborepo/nxのaffected機能やパッケージフィルタを使い、diffが届くパッケージだけを実行します。すべてのプッシュごとに全パッケージを再実行するブロードなユニットパスが、最もよくある予算肥大化のパターンです。

  3. ドキュメント/サイトのビルド → CI専用。 ローカルのb4pushからdocsビルドを外し、PR CIに任せます。誰もローカルで待たないdocsビルドでも、予算のクリープには着実に貢献します。

各ステップで--reporter=verbose(または同等のオプション)を必須にし、タイミングがログで見えるようにします。トリム後も予算を超えるなら、単純に制限時間を上げてごまかしたくなる衝動に抵抗します。強制された25分ゲートは、スキップされ続ける願望的な10分ゲートよりはるかに優れています -- ただし本当に25分かかるなら、それを正直に名付けて計測します。本当の失敗モードは、READMEの中にしか存在しない予算です。

重コンパイル / ネイティブ(cargo、Rust、…)プロジェクト

上記のカット順序はテストをサブセット化するものです -- コストが実行されるスペックの数に比例することを前提にしています。プッシュ前の支配的なコストがコンパイルであるネイティブプロジェクトでは、その軸が間違っています。V8を組み込んだRustワークスペースは、最初のコールドなcargoビルドに15〜30分かかります;コールドツリー上ではあらゆるコンパイルを伴うステップ(cargo clippycargo test)が予算を吹き飛ばし、サブセット化の軸にできるturborepo/nxの「affected」もcargoには存在しません。予算がスペック数ではなくコンパイル時間で決まるとき、テスト数を絞っても助けになりません。

カット順序のネイティブ版は、代わりにコンパイルの軸に沿ってカットを動かします:

  1. 上限付きの予算はウォームな増分(incremental)ツリーを前提とします。 これは直前のビルドのアーティファクトがディスク上に残っている場合にのみ成り立ちます;コールドツリーでは、どんなテストのサブセット化でも回復できません。

  2. コンパイルを伴うフルスイートはb4pushではなくCIに置きます。 CIが権威あるT1ゲートであり(実行ティアを参照)、ウォームキャッシュ上でフルのcargo clippy / cargo testを実行します。b4pushは最初のコールドコンパイルのコストを払う場所ではありません。

  3. b4pushはコンパイルを伴わない高速チェックだけを実行します -- fmt、format、型チェック、JSテスト -- 加えてウォームツリーのlint(増分ツリーを再利用するcargo clippy。ウォームなら安価で、コールドだと予算を吹き飛ばす張本人です)。

  4. フルのローカルコンパイル/テストはオプトインのenvフラグの後ろにゲートします。 例えばB4PUSH_FULL=1です。フルのローカルパスが欲しいコントリビュータが要求できる一方で、デフォルトは上限付きのままに保たれ、CIが強制レイヤーであり続けます。

Tip

JSのカット順序と同じ原理で、軸だけが違います:あちらはテスト数でカットし、こちらはコンパイルでカットします。デフォルトのb4pushは速く信頼されたまま保たれ、コールドコンパイルのコストは、実際にマージをブロックするゲートであるCIに置かれます。

b4pushとCI間のガードマニフェスト・パリティ

失敗モード:ガードセットのドリフト

上記の分割は、b4pushとCIを同じ仕事に対する2つの予算として扱います -- しかし、それぞれが実際にどのライトウェイトなゲートを実行しているかについては何も語りません。実際には両者は独立してドリフトしていきます:締切のプレッシャーの中で誰かがプッシュ前スクリプトに新しいlintルールを追加し、CIワークフローへの反映を忘れる、あるいはCIジョブがリネームされ、対応するb4pushステップが静かに何にもマッチしなくなる、といった具合です。どちらの側もそれ単体では間違っていません -- プッシュ前スクリプトは相変わらず実行され、CIワークフローは相変わらず通ります -- そのため何も壊れていないように見えます。静かに消えたゲートは、その時点からバイパス可能になります:ローカルでは二度と実行されず(誰もb4pushの行を再追加しない)、CIでは二度とrequiredにならない(誰もワークフローステップを再追加しない)からです。修正は、より大きなプッシュ前スクリプトやより大きなワークフローファイルではありません。「両サーフェスに配線されていること」を、手作業の習慣ではなく、チェック可能な不変条件として扱うメカニズムです。

1つのマニフェストエントリ、2つのサーフェス

各ライトウェイトなガードは、マニフェスト内にちょうど1つのエントリを持ち、そのエントリは2つの照合可能な形式を持ちます:ciNeedle -- CIワークフローYAML内で検索される部分文字列 -- と、scriptToken -- プッシュ前スクリプトのマーク付きガード領域(後述)内で検索される部分文字列です。マニフェストは「このガードは両サーフェスに存在しなければならない」という単一の真実の源です。2つの形式が存在するのは、ワークフローステップとスクリプトの行が異なる種類のテキストであり、メタチェックがそれぞれの側で検索するリテラルな部分文字列を必要とするからです。

# scripts/guard-manifest.sh -- one line per guard: id | ciNeedle | scriptToken
# ciNeedle is matched against .github/workflows/*.yml; scriptToken against the
# guards region of scripts/b4push.sh. Both must be present for a guard to be wired.
GUARD_MANIFEST=(
  "lint|pnpm lint|pnpm lint"
  "typecheck|pnpm check|pnpm check"
  "format|pnpm format:md -- --check|pnpm format:md -- --check"
)

マーク付きガード領域

b4pushは手作業でメンテナンスされるスクリプトであり、パリティ契約の一部ではないセットアップ、ロギング、ステップ(例えば後述の重いビルドステップ)も含んでいます。ファイル全体をスキャンする代わりに、メタチェックは明示的にマークされた領域の中だけを見ます:

#!/usr/bin/env bash
# scripts/b4push.sh
set -euo pipefail

# >>> guards:begin -- every scriptToken in scripts/guard-manifest.sh must appear here
pnpm lint
pnpm check
pnpm format:md -- --check
# >>> guards:end

pnpm build

このマーカーは契約を狭く保ちます:guards:begin/guards:endの外にあるステップは、マニフェストエントリなしに自由に存在でき、メタチェックはどの行をカウントすべきか推測する必要がありません。

メタチェックは両サーフェスで実行される

メタチェックスクリプトはマニフェストを読み込み、CIワークフローファイルを各ciNeedleでグレップし、ガード領域を各scriptTokenでグレップして、明示的に許可リストに載っていない(次のセクション)エントリがどちらかの側で欠けている瞬間に失敗します。これはb4pushのステップとしても、requiredなCIジョブとしても両方実行されます -- そのため、ドリフトはどちらに持ち込まれたものであっても、誰かがテストを覚えていた側だけでなく捕まえられます:

#!/usr/bin/env bash
# scripts/guard-parity-check.sh -- fails if a manifest guard is missing from either surface
set -euo pipefail

source scripts/guard-manifest.sh
source scripts/guard-allowlist.sh

GUARDS_REGION="$(sed -n '/# >>> guards:begin/,/# >>> guards:end/p' scripts/b4push.sh)"
CI_YAML="$(cat .github/workflows/*.yml)"

FAIL=0
for entry in "${GUARD_MANIFEST[@]}"; do
  IFS='|' read -r id ci_needle script_token <<< "$entry"

  if ! grep -qF -- "$script_token" <<< "$GUARDS_REGION"; then
    echo "::error::guard '$id' is in the manifest but missing from b4push's guards region"
    FAIL=1
  fi

  is_allowed=0
  for allowed in "${GUARD_CI_ALLOWLIST[@]}"; do
    [ "$allowed" = "$id" ] && is_allowed=1
  done

  if [ "$is_allowed" -eq 0 ] && ! grep -qF -- "$ci_needle" <<< "$CI_YAML"; then
    echo "::error::guard '$id' is in the manifest but missing from CI, and not allowlisted"
    FAIL=1
  fi
done

exit "$FAIL"

両サーフェスへの配線は、1行の追加を2箇所行うだけです:b4push.shguards:begin/guards:end領域の中(これにより、汚れたチェックアウトでもローカルでドリフトを捕まえられます)と、CIワークフローの独立したステップとしてです:

# .github/workflows/ci.yml (excerpt)
- name: Guard-manifest parity check
  run: bash scripts/guard-parity-check.sh

許可リストには理由が必要

あるガードには正当にCI側の対応物が存在しないことがあり得ます -- 例えばスクリプト専用の利便性チェックなど -- しかしそれは誰かが下した決定でなければならず、誰も気づかなかった見落としであってはいけません。許可リストは別ファイルであり、すべてのエントリはその直前の行に必須の# reason:コメントを持ちます。理由のない許可リストは、このメカニズム全体が捕まえようとしているドリフトと見分けがつきません。そのため、理由のないエントリは欠落しているのと同じものとして扱います:

# scripts/guard-allowlist.sh -- guards intentionally exempt from the CI surface
# Every entry MUST carry a "# reason:" comment on the line above it --
# an allowlist entry without one is the same silent hole this mechanism exists to close
GUARD_CI_ALLOWLIST=(
  # reason: covered by CI's separate full-install `build` job (see below),
  # not a substring-matchable script step
  "build"
)

Warning

理由のない許可リストエントリはメカニズム全体を無効化します。 許可リストが存在するのは、CI免除を記録された決定にするためであって、抜け道にするためではありません。メタチェック自体が# reason:コメントを強制していないなら、guard-parity-check.shの中にもう1つ、安価なチェックとして追加してください -- 誰でも裸のidで拡張できる許可リストは、変装したドリフトです。

重いステップは意図的にマニフェストの外に置く

マニフェストがカバーするのはライトウェイトで部分文字列照合可能なガードだけです -- lint、型チェック、フォーマットチェックなど、両サーフェスで単一のコマンドになる類のステップです。build、フルテスト実行、検証は、純粋なスクリプトステップとしてではなく、独立したフルインストールのCIジョブとして実行されるのが正当です:それらは独自の依存関係インストール、独自のタイムアウト、時には独自のランナーを必要とします。これらをマニフェストのciNeedle/scriptTokenの形に押し込めるのは間違った契約でしょう -- CIジョブは、ワークフローステップのrun:行のようにYAMLファイル内の部分文字列ではないからです。この非対称性は意図的なものであり、それこそが上記のbuild許可リストエントリが記録していることです:見落としでマニフェストから漏れているのではなく、重いステップが別種のものだから除外されているのです。

Tip

このパターンは、上記のb4push/exam分割に対して純粋に追加的です:どちらのコマンドが何を実行するかは変えず、その分割が両サーフェスで共有されていると前提しているライトウェイトなゲートが、静かに乖離しないことだけを保証します。

兄弟インベントリ契約としての無視テストマニフェストのパリティ

すぐ上の除外は、一見よりも狭いものです。重いレーンがガードマニフェストの外に置かれるのは、重い_ジョブ_ -- ビルド、フルテスト実行、プラットフォームゲート付きのexam -- が部分文字列照合可能なスクリプトステップではないからです。しかし重いレーンにはメンバーシップリスト、つまりそれが実行するはずのテストの集合もあります。そのリストはジョブではありません -- まさにメタチェックが対象とするために作られた、部分文字列照合可能なインベントリです。これは独自のパリティ契約に値します -- ガードマニフェストの中に折り込む新しい行ではなく、その_兄弟_としての契約です。

罠は、テストを厳密名で選ぶスケジュールexamに特有のものです。cargo/nextestプロジェクトでは、T3のexamは無視(ignored)セットを厳密名の-Eフィルタセット -- -E 'test(=crate::e2e::foo) + test(=crate::e2e::bar)' -- を通して実行します。そして厳密名フィルタセットは許可リストです:テストは、その完全修飾名がそこに明記されている場合にのみexamで実行されます。したがって、真新しい重い#[ignore]のe2eは、デフォルトではどのCIレーンでも実行されません。T1は#[ignore]が付いているのでスキップし、examは誰も名前を追加していないので決して選択しません。テストは宙に浮き、しかもそれを知らせる赤は何も出ません。

これは仮の話ではありません。dev_sibling_watch_1678_e2e -- そのエピックウェーブの目玉となる受け入れテスト -- は、正しい分類タグ、無視テストマニフェスト表の正しい行、そしてe2e-heavyグループへの正しい所属を備えて着地したにもかかわらず、examレーンのフィルタセットからは単に欠けていました。マージされた瞬間から継続的なリグレッション保護はゼロであり、このギャップが表面化したのは、2人の独立したレビュアーがたまたま各自フィルタセットを手作業で差分したからにすぎません。同じ監査は、マニフェスト自体にも同一の失敗モードを掘り当てました:表のヘッダーは無視テストが33件だと主張していたのに、ツリーには34件あったのです -- 以前のマージで1行が静かに欠落していました。どちらも人間だけが突き合わせるドキュメント/インベントリのサーフェスであり、だからこそどちらもドリフトします。

治療法は、ガードパリティのメカニズムを2つ目のマニフェストに向けることです。スクリプトはツリー内のすべての#[ignore = "..."]テストを列挙し、2つのサーフェス -- 無視テストマニフェスト表の行と、スケジュールレーンのフィルタセット内の厳密名ニードル -- に対して双方向の集合一致を要求します。双方向であることが肝心です -- 古くなったマニフェストの行や、宙に浮いたフィルタセットのニードルは、新たに行き場を失ったテストとまったく同じ大きさで失敗するので、どちらのサーフェスもどちら向きにも腐ることができません。(ツリー対マニフェストの半分は、33対34のドリフトを捕まえるものでもあります:欠落した行は、対応する行を持たないツリー上のテストとして現れます。)理由付きの許可リストが、_設計上_どのexamレーンでも実行されないテストを免除します -- pending-feature:で無視されたテストは、その機能が出荷されるまでゲートを持ちません -- 上のガード許可リストと同じ規律に従います:裸の免除は、変装したドリフトです。

#!/usr/bin/env bash
# scripts/ignored-manifest-parity.sh
# The #[ignore]d tests in the tree, the manifest table's rows, and the exam's
# exact-name filterset must name the SAME set. Equality is bidirectional -- a
# stale row or a dangling needle fails just like a newly-homeless test does.
set -euo pipefail

source scripts/ignored-allowlist.sh   # IGNORED_EXAM_ALLOWLIST -- tests that run in no exam lane BY DESIGN (e.g. pending-feature:)

# The tree's ignored set. A raw grep of `#[ignore = "` finds the attribute but
# not the module path; nextest resolves both. --workspace so every member is
# listed, not just the default set. A #[cfg(target_os = "...")] + #[ignore]
# test only compiles on its own platform, so no single run sees the whole tree:
# either union every platform's inventory before the diff, or partition the
# manifest and filterset by platform so each job checks only its own rows.
# The issue-numbered naming keeps testcase paths unique tree-wide; in a
# workspace where two suites can share a path, key by binary and pair each
# needle with binary_id(=...) so the two do not collapse.
tree_names() {
  # --run-ignored ignored-only still lists non-ignored testcases as
  # filter-mismatch entries, so select on .value.ignored -- keys[] alone
  # would emit every test name, not just the ignored ones.
  cargo nextest list --workspace --run-ignored ignored-only --message-format json \
    | jq -r '."rust-suites"[].testcases | to_entries[] | select(.value.ignored) | .key' | sort -u
}

# Both the manifest table and the workflow spell each test as an exact-name
# needle -- test(=fully::qualified::name) -- so one extractor serves both.
needles() { grep -oE 'test\(=[^)]+\)' "$1" | sed -E 's/test\(=(.+)\)/\1/' | sort -u; }

TREE="$(tree_names)"
ALLOW="$(printf '%s\n' "${IGNORED_EXAM_ALLOWLIST[@]}" | sort -u)"

# A stale exemption is as silent a hole as a bare one: every allowlisted name
# must still name a live ignored test (each entry's mandatory "# reason:" is
# enforced exactly as in the guard allowlist above).
STALE="$(comm -13 <(echo "$TREE") <(echo "$ALLOW"))"
[ -z "$STALE" ] || { echo "::error::allowlist names a test that no longer exists: $STALE"; exit 1; }

# tree <-> manifest: every ignored test has a row, and every row a live test
# (this half is also what catches a header count of 33 against a tree of 34)
diff <(echo "$TREE") <(needles docs/ignored-test-manifest.md) \
  || { echo "::error::ignored-test tree and manifest table disagree (diff above)"; exit 1; }

# (tree - allowlist) <-> filterset: every non-exempt ignored test is selected,
# and no needle points at a test that no longer exists
diff <(comm -23 <(echo "$TREE") <(echo "$ALLOW")) <(needles .github/workflows/exam.yml) \
  || { echo "::error::exam filterset out of sync with the ignored-test tree (diff above)"; exit 1; }

これを両方のサーフェスに配線します -- ガードパリティチェックとまったく同じように:ローカルのプッシュ前フィードバックのためのb4pushステップと、requiredなCIジョブ -- マージをブロックする強制サーフェス -- です。そうすれば、2人のレビュアーが目でフィルタセットを差分するのを待つのではなく、新しい#[ignore]テストが行き場を失った瞬間に失敗します。フィルタは厳密名のままに保ちます -- nextestの厳密名の形はtest(=fully::qualified::name)であり、部分文字列フィルタ(test(foo))はレーンを静かに広げてしまい、「名前を明記した1テストにつき1つの許可リスト判断」という契約を無効にします。

Tip

これはガードマニフェストの兄弟として保ち、その拡張にはしないでください。ガードマニフェストは「このライトウェイトなゲートはb4pushとCIの両方に配線されているか?」に答えます。無視テストマニフェストは「ツリーが定義するすべての重いテストは、実際にどこかのレーンで実行されているか?」に答えます。同じメタチェックの形、同じ理由付き許可リストの規律、しかし2つの異なるインベントリ -- ひとつに折り込めば、片方の契約のスコープ記述がもう片方をカバーするために歪められてしまいます。

スケジュールCI再試験ワークフロー

Note

スケジュールのリッチCIはT3 / カットオーバー後の関心事です:プロジェクトのテストスイートが専用のナイトリーランナーを正当化できるほど成熟したカットオーバー時点で構築します。それまでは、ここで説明するローカルexamレーンが暫定手段です。全体の成熟度アークにおける位置づけは実行ティアを参照してください。

完全なスケルトンです。スケジュール実行手動ディスパッチの両方を受け付け、タグ付けされた重いレーン(@gpu@interactive@macos-only -- タグ分類は実行ティアを参照)だけを実行し、失敗時には重複排除されたIssueを起票します:

# .github/workflows/exam.yml
name: exam

on:
  schedule:
    # Off-minute on purpose: GitHub delays -- and under load, DROPS -- runs
    # queued at the top of the hour. Nightly ~03:43 UTC.
    - cron: "43 3 * * *"
  # On-demand runs for pre-merge escalation (see below)
  workflow_dispatch:

permissions:
  contents: read
  issues: write

jobs:
  exam:
    # GitHub-hosted macOS runners are Apple silicon from macos-14 onward
    runs-on: macos-14
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps webkit

      # Run only the tagged heavy lanes -- everything else already ran on PR CI
      # The json reporter feeds file-exam-issue.sh below; list keeps the live log readable
      - name: Run heavy lanes
        env:
          PLAYWRIGHT_JSON_OUTPUT_NAME: playwright-report/report.json
        run: pnpm test:e2e --grep "@gpu|@interactive|@macos-only" --reporter=list,json

      - name: File or update the failure tracking issue
        if: failure()
        env:
          GH_TOKEN: ${{ github.token }}
        run: bash scripts/file-exam-issue.sh

      - name: Close the tracking issue on green
        if: success()
        env:
          GH_TOKEN: ${{ github.token }}
        run: bash scripts/file-exam-issue.sh --green

ランナーに関する注意

リリースラウンドのブランチトポロジーを採用している場合は、この夜間スケジュールを本番ではなく蓄積ブランチ(例:develop)に向けてください -- ほとんど動かない本番ブランチに向けた夜間実行がラウンドの間に何も新しく学べない理由については、リリースラウンド: develop→main ブランチ戦略 § develop に向けた夜間の重いレーンを参照してください。

  • GitHubホストのmacOSランナーはAppleシリコンですmacos-14以降):実WebKit、Metalバックエンドのレンダリング。ソフトウェアレンダリングのCIランナー上ではピクセルレベルのスペックが失敗する、canvas/GPUヘビーなWebアプリにとっては、これだけで偽の赤と信頼できる結果の分かれ目になり得ます。

  • サードパーティのホスト型macOSプロバイダも存在します。GitHubホストのプールではスイートに対して遅すぎる、または高すぎる場合の選択肢です。

  • 自前ハードウェアのセルフホストランナーはエスカレーションであって、デフォルトではありません。 エスカレーションする場合:main上でスケジュール実行のみ、パブリックリポジトリでは決してPRトリガーにしない、そしてオフライン検知と組み合わせること -- 例えば、セルフホストジョブがN時間以内に報告してこなかったら警告する、ホスト型ランナー上のコンパニオンジョブ -- これにより、眠っているマシンが「沈黙による緑」ではなく、見える形になります。

  • ランナーの形状・料金・サイジングのルールは別ページにあります。 ラベルの全マトリクス、macOSのコスト倍率、そして「大きい/別のランナーが本当に効くのはいつか」を判断する4つのルールについてはCIランナーのサイジングを参照してください。

Warning

パブリックリポジトリでは、PRがセルフホストランナーをトリガーできるようにしては絶対にいけません。 PRトリガーのセルフホストランナーは、プルリクエストを開いた誰のコードでも、あなたのマシン上で実行してしまいます。セルフホストのexamジョブはmain上のスケジュール実行のみに保ちます。

T3を外部依存のドリフトネットとして使う

ここまでの内容はすべて、T3が存在する理由をPRランナーが提供できないハードウェアやプラットフォームに到達するためだと前提しています。もう1つの、これとは無関係なトリガーが、別の理由から同じティアにテストを送り込みます:プロジェクトの中核的な依存関係が、リポジトリとは独立して動く外部パッケージである場合です -- pin留めして消費している公開npmパッケージが、リポジトリの制御が及ばないタイミングで破壊的変更を出荷することがあります。この種のドリフトはPRゲートには原理的に捕まえられません:PRゲートはlockfileからインストールするため、そのlockfileが生成されて以降レジストリで何が変わったかを決して観測できないからです。この形にはGPUもmacOSも特別なハードウェアも不要です -- ランナーはT1に合わせるべきです(素のubuntu)。トリガーが外部のドリフトであって、プラットフォームの能力ではないからです。

2つのレーン:同一スイートの再実行とレジストリ統合

このパターンは、異なる仕事を持つ2つのレーンに分かれます:

  1. 夜間の同一スイート再実行。 PRゲートが実行するのと全く同じCIセーフなスイートを、そのまま再実行します。このレーンのために新しいテストを書くことはありません -- その価値は純粋にメインブランチのドリフトネットとしてのものであり、個々のPRとは無関係な理由でマージの間に退行したものを捕まえます。

  2. レジストリ統合レーン。 レジストリからの実際のpnpm installに続けて、公開されたパッケージをエンドユーザーと同じように消費するスキャフォールドプロジェクトのフルビルドを行います。実際に外部世界を運動させるのはこのレーンです:PRゲートのlockfile固定インストールでは、欠落したエクスポート、パッケージングのバグ、最小スキャフォールド上での解決不能な依存関係を観測できません -- これらはインストールが生きたレジストリに対して解決されたときにしか表面化しないからです。

レジストリ統合レーンは、一連の公開時ゲートのスケジュール版のいとこです:メタパッケージ + optionalDependencies経由のプラットフォーム別バイナリとして出荷されるCLIにおいて、これらのゲートは公開パイプラインそのもの -- tarballの伝播、ファイルモード、冪等な公開 -- を、リリースのたびに1回、すべてのプラットフォームにわたって検証します。このレーンはレジストリ側のリグレッションを夜間に、1つのプラットフォーム上で捕まえます。あちらのゲートは公開側のリグレッションを即座に、すべてのプラットフォームで捕まえます。

# .github/workflows/drift-net.yml
name: drift-net

on:
  schedule:
    # Off-minute on purpose: GitHub delays -- and under load, DROPS -- runs
    # queued at the top of the hour. Nightly ~04:29 UTC.
    - cron: "29 4 * * *"
  workflow_dispatch:

jobs:
  same-suite-rerun:
    # Matches T1's runner -- the trigger is external drift, not platform capability
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:ci # the exact PR-gate suite, no new tests

  registry-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      # GitHub Actions sets CI=true, which flips pnpm's --frozen-lockfile default to true --
      # override it explicitly so this install doesn't silently become the frozen one
      - run: pnpm install --no-frozen-lockfile
      # This is the step that genuinely resolves against the live registry: `pnpm create`
      # scaffolds a fresh project with no lockfile of its own, unlike the repo install above
      - run: pnpm create my-scaffold ./scaffold-under-test
      - run: pnpm --dir ./scaffold-under-test build

Warning

pnpm installだけでは、生きたレジストリに対する解決は保証されません。 GitHub ActionsはすべてのジョブでCI=trueを設定し、pnpmの--frozen-lockfileのデフォルトはCI=trueがセットされているとtrueに切り替わります -- そのため、このレーンで裸のpnpm installを使うと、生きたレジストリに対する解決ではなく、frozenインストールになってしまいます。--no-frozen-lockfileはそのデフォルトを上書きしますが、それでもlockfileが最新であれば、新たに解決すべきものは何もありません。実際にレジストリを運動させるのは上のpnpm createステップです:ゼロからlockfileなしのプロジェクトをスキャフォールドします。

両方のレーンは、後述する「ワークフローごとに1つのトラッキングIssue」パターンにそのまま起票します -- 独自のIssue起票の仕組みを別に持つ必要はありません。

Tip

ランナーの整合。 上のmacOSスケルトンは重い/プラットフォーム依存のトリガー向けであり、ここには当てはまりません。外部依存のドリフトネットは、T1と同じ素のubuntuランナー上で実行します -- これが「重い」「プラットフォーム依存」と並ぶ3つ目のT3トリガーとしてどう読めるかは実行ティアを参照してください。

1か月のテレメトリ:シグナルがどこに現れたか

このパターンをまさにそのまま運用しているダウンストリームプロジェクトの1か月分の本番テレメトリが、どちらのレーンが働きに見合っているかを決着させました:その月のスケジュールexamの失敗は5件すべてがレジストリ統合レーンから来ており -- 欠落したパッケージエクスポート、パッケージングのバグ、最小スキャフォールド上での解決不能な依存関係など -- 同一スイート再実行レーンからはゼロでした。それぞれの失敗は、夜間ジョブが捕まえてから1〜2日以内に修正されました。

この非対称性は、各レーンが構造的に何を見られるかから導かれます。同一スイート再実行レーンは、PRゲートがすでに実行したのと同一のスイートを、同一のlockfileに対して実行します -- PRゲート自身も捕まえたはずのメインブランチのドリフトしか捕まえられず、T1が健全であればそれはまれです。レジストリ統合レーンだけが生きたレジストリに対して解決を行うため、レジストリ側のリグレッションをそもそも見られる立場にあるのはこのレーンだけです。だからといって再実行レーンが無価値というわけではありません -- 新しいテストがゼロで済むため追加コストはほぼゼロです -- ですが、実際のキャッチが起きるのはレジストリ統合レーンのほうです。

1つのトラッキングIssue、実行ごとに1つではなく

1週間赤いままのナイトリージョブが、7つのIssueを起票してはいけません。実行ごとの起票はシグナルを重複の山に埋もれさせ、全員にそのラベルを無視することを学習させます。ルールは:ワークフローごとにオープンなトラッキングIssueは1つ -- 失敗が続く間はそこにコメントし、examが緑に戻ったらクローズし、次の失敗には新しいIssueを開かせます。

上のスケルトンのif: failure()ステップは、このスクリプトを呼びます:

#!/usr/bin/env bash
# scripts/file-exam-issue.sh -- one tracking issue per workflow, never one per run
set -euo pipefail

LABEL="exam-failure"
# Workflow name goes in the issue title so the dedup query matches THIS
# workflow's issue, not another workflow's -- two jobs can share the label
WORKFLOW_NAME="${GITHUB_WORKFLOW}"
TITLE="exam: ${WORKFLOW_NAME} scheduled heavy run is failing"
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"

# DRY_RUN=1: echo the gh command instead of running it -- safe to test against a fixture report.json
run_gh() {
  if [ "${DRY_RUN:-}" = "1" ]; then
    echo "+ gh $*"
  else
    gh "$@"
  fi
}

# Find the open tracking issue for THIS workflow: the label narrows the
# candidates, the title match keeps a second workflow that reuses this
# script (and label) from appending to the wrong issue
find_existing() {
  gh issue list --label "$LABEL" --state open --json number,title \
    | jq -r --arg t "$TITLE" '[.[] | select(.title == $t)] | .[0].number // empty'
}

# --green path: close the tracking issue if one is open, then exit.
# Must come BEFORE reading report.json -- green runs produce no failure report.
if [ "${1:-}" = "--green" ]; then
  EXISTING="$(find_existing)"
  if [ -n "$EXISTING" ]; then
    run_gh issue comment "$EXISTING" --body "Exam green: ${RUN_URL}"
    run_gh issue close "$EXISTING"
  fi
  # No open issue -- nothing to do
  exit 0
fi

# Failure path: collect failing spec names from the reporter's output
# (example: Playwright's JSON reporter written to playwright-report/report.json)
FAILED_SPECS="$(jq -r '.. | objects | select(.ok == false) | .file? // empty' \
  playwright-report/report.json | sort -u)"

BODY="Scheduled exam failed.

Run: ${RUN_URL}

Failing specs:

\`\`\`
${FAILED_SPECS}
\`\`\`"

# Is there already an open tracking issue for THIS workflow?
EXISTING="$(find_existing)"

if [ -n "$EXISTING" ]; then
  # Yes: append this run to it -- do NOT open a duplicate
  run_gh issue comment "$EXISTING" --body "$BODY"
else
  # No: create the single tracking issue with the fixed label and title
  run_gh issue create \
    --title "$TITLE" \
    --label "$LABEL" \
    --body "$BODY"
fi

各レポートには、修正セッションが必要とする情報が含まれます:固定ラベルに加えてタイトル内のワークフロー名(重複排除クエリがこのワークフローのIssueを見つけ、別のワークフローのIssueと取り違えないため)、失敗したスペック名、そして実行URLです。

Note

初回実行前にスクリプトの実行可能ビットをコミットしておきます:git update-index --chmod=+x scripts/file-exam-issue.sh。実際のIssueに触れずにスクリプトをローカルでテストするには、DRY_RUN=1付きで実行します -- run_gh()ラッパーがghコマンドを実行する代わりにエコーするため、フィクスチャ用のreport.jsonに対して両方のパスを検証できます。

レポーターのパースフィクスチャを実際にキャプチャしたレポートに固定する

上記のスクリプトはPlaywrightのJSONレポーター出力をパースしています。このパーサー(またはツールの機械生成出力を消費するスクリプト)のユニットテストを書く場合、テストで使うフィクスチャはツール自体から実際にキャプチャしたアーティファクトでなければなりません -- スキーマの思い込みに合わせて手書きした形ではいけません。

ルール:ツールの機械的な出力をパースするスクリプト -- テストランナーのJSONレポーター、カバレッジJSON、バンドラーのstatsファイル -- のユニットテストを書くときは、ツールの実際の出力を代表的なサブセットにトリムしてフィクスチャとしてコミットします。具体的なレシピ:

  1. 実環境でツールを一度実行する。

  2. そのJSON出力を代表的なサブセットにトリムして __fixtures__/real-report.json として保存する。

  3. そのファイルに対してパーサーのユニットテストを書く。

将来のバージョンでツールがスキーマを変更したとき、テストは声高に失敗します -- 現実と合致しなくなった架空の構造に永遠に同意し続けるのではなく。

この罠にはまっているときの兆候: パーサーのユニットテストは緑なのに、新しいツール実行から得た実際のデータに対してスクリプトが空または無意味な出力を返す。これが、現実ではなく思い込みに合わせたフィクスチャの典型的なシグネチャです。

Warning

合成フィクスチャが安全なのは、あなたがそのコントラクトを所有している場合に限ります。サードパーティツールの出力に対しては、真実を作ることはできません -- キャプチャするしかありません。手書きフィクスチャが適切なのは、テスト対象のスキーマを読者が所有している場合です:たとえば remark/rehypeプラグインのテスト でのmdastツリーファクトリ(mdastコントラクトはあなたが所有)や、レベル3のビルド出力テスト での合成的なインナーバンドルオブジェクト(バンドルの形はあなたが所有)がそれにあたります。PlaywrightやJest、Viteのようなツールの出力スキーマは彼らが変更する権限を持っているため、実際のアーティファクトをキャプチャすることだけが誠実でいられる唯一の方法です。

flakyテレメトリのメカニズム

このサイトの他の場所で述べられている2つのルールは、方針であってメカニズムではありません:実行ティアは「リトライ後のパスはトリアージのシグナルであって、成功ではない」と述べ、重いテストの判断ルールは「隔離された @flaky テストはスケジュールティアで許容失敗として実行され続け、トラッキングIssueに新鮮な失敗データを流し続ける」と述べています。しかしどちらのページも、リトライ後にパスしたテストがどう検知されるのか、そのデータがどうやって実際にIssueへ届くのかは述べていません。以下は、あるダウンストリームプロジェクトが構築し、鍛え上げたメカニズムです -- この2つのルールを実現するためのものです。

リトライパスの検知

PlaywrightのJSONレポーターは、少なくとも1回失敗した後、リトライ予算の中でパスしたテストに "status": "flaky" を付けます。このフィールドを主要なシグナルとして信頼しますが、それだけに頼らないこと -- レポーターラッパーや古いバージョンのPlaywrightがこのフィールドを落とすエッジケースに備えて、構造的な形(results[] に2件以上のエントリがあり、最後のエントリが "status": "passed")へのフォールバックも用意します:

#!/usr/bin/env bash
# scripts/annotate-flaky.sh -- detect retry-passes, annotate, then feed each into its own tracking issue
set -euo pipefail

RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"

# Playwright marks a retry-pass "flaky" -- fall back to the structural shape
# (more than one result, the last one "passed") for reporters that drop the field.
# Emit identity, not just title -- two specs may share a title
# (see deflaking-recipe.mdx#part-1-—-root-cause-catalog-key-cross-run-triage-on-test-identity-not-on-test-title)
jq -r '
  .. | objects
  | select(.tests? and .title?)        # a "spec" node: has a title and per-project test runs
  | . as $spec
  | .tests[]
  | select(
      .status == "flaky"
      or ((.results // []) | length > 1 and (.[-1].status == "passed"))
    )
  | "\(.projectName // "default")\t\($spec.file)\t\($spec.title)"
' playwright-report/report.json |
while IFS=$'\t' read -r PROJECT FILE TITLE; do
  echo "::warning file=${FILE}::[${PROJECT}] ${TITLE} -- passed only after retry, record and schedule the fix"

  # The annotation above is decoration until something reads it --
  # file or append to a deduped tracking issue, one per test, not one per run.
  # Key the tracking issue on project + file + title. Keyed on title alone, two specs
  # sharing a name collect telemetry into one issue that neither fix closes.
  FLAKY_TITLE="flaky: [${PROJECT}] ${FILE} › ${TITLE}"
  EXISTING="$(gh issue list --label "flaky" --state open --json number,title \
    | jq -r --arg t "$FLAKY_TITLE" '[.[] | select(.title == $t)] | .[0].number // empty')"

  if [ -n "$EXISTING" ]; then
    gh issue comment "$EXISTING" --body "Retry-pass warning: ${RUN_URL}"
  else
    # Best-effort: a fork-originated PR's read-only GITHUB_TOKEN cannot create
    # issues -- log and move on, the annotation above still stands either way
    gh issue create --title "$FLAKY_TITLE" --label "flaky" --body "Retry-pass warning: ${RUN_URL}" \
      || echo "::warning::could not file the tracking issue (read-only token?) -- see the annotation above"
  fi
done

一致した各行はGitHub Actionsのランサマリー上のアノテーションになります -- 生のJSONを開かなくても見えます。

警告には受け手が必要

Warning

発行されたアノテーションは、起票されたIssueではありません。 アノテーションはランサマリーページ上にしか表示されません -- 購読もなければ通知もなく、誰かがたまたまその特定の実行を開かない限り表面化しません。これは、あるダウンストリームプロジェクトが実際にはまった運用上の罠です:上のようなスクリプトを出荷し、リトライパスの警告は何か月もランログに積み上がり続けましたが、そのうちの1つも隔離パイプラインのトラッキングIssueには届きませんでした -- 誰もそれを消費していなかったからです。アノテーションは、何かがそれを読んで行動しない限り、単なる飾りです。だからこそ上のスクリプトは、後回しにする「フォローアップ」としてではなく、同じループの1回のイテレーションの中で ::warning:: と重複排除されたIssue呼び出しを必ずペアにしています。

隔離レーンのテレメトリ

すべての @flaky テストの上にある必須のインラインコメント(重いテストの判断ルールの証跡ルール)は、機械可読なポインタでもあります:そこから各テストのトラッキングIssue URLを抜き出し、スケジュール実行のたびにそのテストのpass/failを投稿します。fix/demote/deleteの締切判断は、フレイキーがまだ起きているかどうかの当て推量ではなく、データドリブンになります:

#!/usr/bin/env bash
# scripts/quarantine-telemetry.sh -- post pass/fail to each @flaky test's own tracking issue
set -euo pipefail

RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
# spec.file is relative to the report's config.rootDir (usually the testDir),
# not the repo root -- resolve it before grepping or every lookup misses.
ROOT_DIR="$(jq -r '.config.rootDir' playwright-report/report.json)"

jq -r '
  .. | objects
  | select(.tests? and .title?)
  | select(.title | test("@flaky"))
  | . as $spec
  | .tests[]
  | "\(.status)\t\($spec.file)\t\($spec.title)"
' playwright-report/report.json |
while IFS=$'\t' read -r STATUS FILE TITLE; do
  # The tracking-issue URL sits in the mandatory inline comment directly above test().
  # Scope to the one resolved file, not all of e2e/ -- head -1 across the tree picks
  # the wrong issue whenever two specs share a title.
  ISSUE_URL="$(grep -n -B1 -F "\"${TITLE}" "${ROOT_DIR}/${FILE}" 2>/dev/null \
    | grep -oE 'https://github\.com/\S+/issues/[0-9]+' | head -1)" || true
  [ -z "$ISSUE_URL" ] && continue  # missing paper trail -- caught separately by the tag-to-issue guard
  ISSUE_NUM="${ISSUE_URL##*/}"
  VERDICT="$([ "$STATUS" = "expected" ] && echo "pass" || echo "fail")"
  gh issue comment "$ISSUE_NUM" --body "Quarantine telemetry: **${VERDICT}** -- ${RUN_URL}"
done

失敗許容(allowed-to-fail)は continue-on-error ではなくキャプチャした終了コードで実現する

隔離レーンは本来赤を許容するものです -- @flaky テストが失敗するのは想定内であり、このレーンの仕事はその失敗をトラッキングIssueに記録することであって、何かをブロックすることではありません。「このステップは失敗してよい」を表す一番わかりやすい方法は continue-on-error: true ですが、これは罠です。

Warning

continue-on-error: trueif: failure() の組み合わせは、死んだ起票ステップです。 ステップレベルの continue-on-error は、ステップの steps.<id>.outcomefailure のまま保ちますが、conclusionsuccess に書き換えます。するとジョブは成功として結了するため、後続の if: failure() は決して真になりません -- 起票ステップはデッドコードになり、レーンは緑を表示したまま腐っていきます。

# BROKEN: the job concludes success, so `if: failure()` can never be true --
# the filing step below is dead code and the lane rots while showing green
- name: Run quarantine lane
  continue-on-error: true
  run: pnpm test:e2e --grep "@flaky" --pass-with-no-tests

- name: File or update the tracking issue
  if: failure()          # never fires
  run: bash scripts/file-exam-issue.sh

代わりに終了コードをキャプチャし、起票のパスをその終了コードから駆動して、ステップは意図的に緑で終わらせます:

- name: Run quarantine lane (allowed-to-fail BY DESIGN)
  id: quarantine
  env:
    PLAYWRIGHT_JSON_OUTPUT_NAME: playwright-report/report.json
  run: |
    set +e
    pnpm test:e2e --grep "@flaky" --pass-with-no-tests --reporter=list,json
    rc=$?
    set -e
    echo "rc=$rc" >> "$GITHUB_OUTPUT"
    # Deliberate: this lane is green-by-design. The captured rc -- not the
    # job status -- drives the filing step below.
    exit 0

- name: Post per-test telemetry (pass AND fail)
  env:
    GH_TOKEN: ${{ github.token }}
  run: bash scripts/quarantine-telemetry.sh

- name: File or update the lane tracking issue
  if: steps.quarantine.outputs.rc != '0'
  env:
    GH_TOKEN: ${{ github.token }}
  run: bash scripts/file-exam-issue.sh

これを誠実に保つための3つのルール:

  • 依拠しているセマンティクスを明示する。 ステップレベルの continue-on-errorsteps.<id>.outcomefailure のまま保ちつつ、conclusionsuccess にします。そして if: failure() はジョブのステータス(conclusion に従う)を読みます。そのため終了コードのキャプチャには、YAMLネイティブな代替手段があります:continue-on-error: true を残し、ステップに id を付け、後続ステップを if: steps.<id>.outcome == 'failure' でキーイングするのです。どちらでも動きます -- 罠は具体的に continue-on-errorfailure()組み合わせにあります。(ジョブレベルの continue-on-error なら if: failure() は動き続けますが、赤を許容すべきレーンの実行ごとに赤い ✗ を描いてしまいます -- 人々がそれを無視することを学習するほどにノイジーです。)

  • 緑になるのが設計であることは、記録された決定でなければなりません。 exit 0 に付けるインラインコメント -- 「allowed-to-fail BY DESIGN, rc drives the filing step」 -- は必須です。テスト実行直後の裸の exit 0 は、さもなければバグに見え、いずれ誰かがレーンを緑に保っている行を削除して「修正」してしまいます。

  • 起票のパスが発火することを -- 一度 -- 証明する。 ブランチ上で隔離されたテストを壊し、gh workflow run でそのレーンを実行し(スケルトンにはすでに workflow_dispatch があります)、トラッキングIssueに実際にコメントが付くことを確認します。一度も発火したことのない起票のパスは飾りです -- 上の「読まれないアノテーション」と同じ罠です。

タグとissueを紐付けるガード

証跡ルール(「@flaky は、すぐ隣にインラインのissue URLがある場合にのみ有効」)は、それを機械的に強制する何かがあって初めて現実になります。コメントの欠落でCI/プッシュ前スクリプトを失敗させれば、そのルールは、エージェントが忘れるかもしれない慣習から、スキップできないゲートに変わります:

#!/usr/bin/env bash
# scripts/guard-flaky-paper-trail.sh -- fail the gate if a @flaky test lacks its inline issue URL
set -euo pipefail

MISSING=0
while IFS=: read -r FILE LINE _; do
  ABOVE="$(sed -n "$((LINE - 1))p" "$FILE")"
  if ! echo "$ABOVE" | grep -qE '// quarantined: https://github\.com/\S+/issues/[0-9]+'; then
    echo "::error file=${FILE},line=${LINE}::@flaky test is missing its inline tracking-issue comment"
    MISSING=1
  fi
done < <(grep -rn 'test(.*@flaky' --include='*.spec.ts' e2e/)

exit "$MISSING"

信頼できるレーンのための2つの小さな修正

Tip

空レーンの使い勝手。 隔離レーンの健全な終着点は @flaky テストがゼロになることです。--pass-with-no-tests がなければ、何にもマッチしない --grep はPlaywrightの終了コードを非ゼロにし、「隔離すべきものが何もない」という状態でスケジュール実行を赤くしてしまいます -- 本来起きてほしいこととは逆です:

pnpm test:e2e --grep "@flaky" --pass-with-no-tests

Note

ランナーの癖:JSONレポーターのリダイレクト。 古い例でまだ見かける --reporter=json:<path> のコロン構文は、そもそもPlaywrightの正式なCLI構文だったことがありません -- --reporter は裸のレポーター名かカンマ区切りのリストしか受け付けたことがなく、これは特定バージョンでのリグレッションではありません。上のexamワークフローのスケルトンですでに使われている PLAYWRIGHT_JSON_OUTPUT_NAME が、JSONレポーターの出力パスをリダイレクトするサポートされた方法です。

リトライ予算とトレース取得を組み合わせる

リトライでのみパスするテストはトリアージのシグナルですが、アーティファクトのないシグナルはトリアージセッションを無駄にします。リトライ予算(リトライ予算を参照)と、フレイキーを実際に明らかにするリトライでのトレース取得を組み合わせます:

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: "on-first-retry",
  },
});

Warning

トレースは playwright-report/ ではなく、ランナーの outputDir に置かれます。 Playwrightのデフォルトの outputDirtest-results/ です -- 失敗・リトライした試行のトレース、スクリーンショット、動画はそこに書き出されます。playwright-report/ は結果をまとめる別のディレクトリです:上のexamワークフローではJSONレポーターの出力(report.jsonPLAYWRIGHT_JSON_OUTPUT_NAME経由)を保持し、代わりにhtmlレポーターを設定していればHTMLレポートを保持します。いずれにせよ、生のトレースファイルを自動的にバンドルすることはありません。playwright-report/ だけを取得するアーティファクトアップロードのステップは、トレースを静かに全部失います:

- uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: exam-artifacts
    path: |
      playwright-report/
      test-results/

配置の問題は逆向きにも切れます:outputDirtest-results/)は毎回のラン開始時に空にされるため、ラン間で残すつもりでそこに置いたものは次のランに削除されます——残したいアーティファクトは outputDir の外に置かなければならないを参照。

オンデマンドディスパッチ:マージ前のエスカレーション

スケジュールテストへの定番の反論はフィードバックの遅延です:今朝マージされたリグレッションは明日まで見えない、というものです。workflow_dispatchはこの反論に上限を設けます。スケジュールティアでしかカバーされていないコードに変更が触れるときは、希望的観測でマージしないこと -- そのブランチに対してexamをディスパッチし、判定を待ちます:

# The change touches code covered only by scheduled-tier tests?
# Run the exam on the branch BEFORE merging -- do not wait for tonight's cron.
gh workflow run exam.yml --ref my-feature-branch

# Follow the run to its verdict
gh run watch "$(gh run list --workflow=exam.yml --limit 1 \
  --json databaseId --jq '.[0].databaseId')"

これにより、スケジュールティアの最大の弱点が上限付きのコストに変わります:デフォルトのフィードバックループは夜間で、本当に待てない変更には手動の脱出ハッチが用意されている、というわけです。

夜間試験:プロジェクトスコープのエージェントスキル

スケジュールCIジョブは意図的に薄く作ります:実行し、報告し、起票するだけ。同じアイデアのよりリッチなバージョンは、テストが最も信頼できる場所 -- ゴールドスタンダードのマシンそのもの -- の上で、夜間に、素のCIにはできない部分をエージェントが担う形で実行します。

これをプロジェクトスコープのエージェントスキルとして定義します:リポジトリのエージェント設定にチェックインされたスラッシュコマンド形式のエントリポイントです。手順がバージョン管理され、レビュー可能で、毎晩同一になります。就寝前に手動で起動します:

# Before sleep, on the gold-standard machine
/exam          # run heavy lanes, triage failures, file deduped issues
/exam --fix    # ...and additionally pick up to 3 issues and fix them in-session

スキルのパイプライン:

  1. プリフライト -- ツリーがクリーンで、ブランチがmainで、リモートに対して最新でない限り、開始を拒否する

  2. スリープ防止ラッパー -- スイートの途中でマシンが眠らないようcaffeinate -iの下で実行する

  3. プラットフォームゲート付きの重いレーンを実行 -- CIのexamと同じタグを実行する

  4. エージェントによるトリアージ -- 失敗をクラスタリングし、既知の環境ノイズシグネチャを本物のリグレッションから分離する

  5. 失敗クラスタごとに重複排除されたIssue -- クラスタごとに1つ。スペックごとでも実行ごとでもなく

  6. --fixモード -- 起票されたIssueから最大N件を拾い、そのセッション内で修正し、朝のレビューに備える

  7. 朝のサマリー -- 1つのメッセージ:何が実行され、何が失敗し、何がノイズで、何が起票され、何が修正されたか

最初の2ステップは素のシェルです:

# Preflight -- refuse to run on a dirty or stale tree
git status --porcelain | grep -q . && { echo "dirty tree"; exit 1; }
[ "$(git branch --show-current)" = "main" ] || { echo "not on main"; exit 1; }
git pull --ff-only

# Keep the machine awake for the whole run (macOS)
caffeinate -i pnpm test:e2e --grep "@gpu|@interactive|@macos-only"

トリアージのステップこそが、これがcronスクリプトではなくエージェントスキルである理由です。ゴールドスタンダードのマシン上では赤はおそらく本物ですが、長く生きている重いスイートには既知のノイズシグネチャが蓄積していきます:初回実行のフォントキャッシュ警告、コールドブート直後のタイミング依存のファーストフレームなど。エージェントは失敗をクラスタリングし、プロジェクトのエージェント指示に記録されたノイズシグネチャと照合し、残ったものだけをIssueにします。この判断 -- 「この3つの赤は1つのリグレッション、4つ目の赤は火曜日からある既知のノイズ」 -- こそ、素のCIジョブには決してできないことです。

Note

それでもスケジュールCIジョブは残します。 夜間試験は、人間が実行を覚えていることと、マシンが起きたままでいることに依存します -- まさにローカル専用レーンを失格にしたのと同じ失敗モードです。ペアであることに意味があります:夜間試験はトリアージと修正を備えたリッチなレーン、スケジュールCI再試験は誰も覚えていなかった夜にも必ず実行される薄いバックストップです。

実装時のスコープを絞った重いテスト実行

cronも夜間試験も事後的なものです:変更が取り込まれてから何時間も後にリグレッションを捕まえます。重いレーンのテストでしかカバーされていないコードに触れる変更を実装しているときは、今夜を待たないこと -- 関連する重いスペックだけを、変更にスコープを絞って、対応できるホスト上で今すぐ実行します:

# The change touched the shortcut engine -- run just its heavy specs,
# on the capable host, before declaring the work done
pnpm test:e2e --grep "@interactive" e2e/shortcuts-*.spec.ts

難しいのはスペックの実行ではなく、変更がどのスペックに関係するかを知ることです。それには変更→スペックのマッピングが必要で、2つの規約で機械的に保てます:

  • Issue番号付きのスペックファイル名 -- e2e/issue-123-shortcut-paste.spec.tsのような名前は、スペックをその動機となった変更に紐付け、grepで見つけられるようにします

  • プロジェクトのエージェント指示内のモジュール→スペック表 -- エージェント(または人)が、変更がどの重いスペックに関係するかを引けるようにします:

<!-- In the project's agent instructions: change-to-spec mapping -->

| When a change touches... | Run these heavy specs first            |
| ------------------------ | -------------------------------------- |
| src/shortcuts/**         | e2e/shortcuts-*.spec.ts (@interactive) |
| src/render/gpu/**        | e2e/render-*.spec.ts (@gpu)            |
| src/export/video/**      | e2e/export-video.spec.ts (@gpu)        |

Tip

スコープを絞った実行は、口伝ではなくエージェント指示内の明文化された要件にします:「重いレーンのテストでしかカバーされていないコードに変更が触れた場合、作業完了を宣言する前に、マッピングされたスペックを対応ホスト上で実行する(またはそのブランチでexamワークフローをディスパッチする)」。

レイヤー化された全体像

サーフェス実行内容タイミング失敗時
b4push(ローカル)高速な上限付きパス毎プッシュ前プッシュ前に修正
PR CICIセーフなゲートすべてのPRマージブロック
スケジュールexam(CI)macOSランナー上のタグ付き重いレーン夜間cron + 手動ディスパッチ重複排除されたトラッキングIssue
夜間試験(ローカルスキル)重いレーン + エージェントトリアージ就寝前に手動クラスタごとのIssue、任意で--fix
スコープ付き重い実行(ローカル)変更に関連するスペックのみ実装中完了宣言の前に修正

どの単一のサーフェスもセーフティネットではなく、レイヤリングこそがネットです。そもそもどのテストが重いレーンに属するべきかは重いテストの判断ルールが決め、全体で使われるティアの語彙(T0--T4)は実行ティアで定義されています。

Revision History

作成更新