Renovateのpest v5更新は、プラグインを置き去りにしてlockを作れなかった
Renovateが作った pestphp/pest を ^4.7 → ^5.0 に上げるPRで、CIが落ちました。反射的にPest 5の破壊的変更を疑いましたが、静的解析にもテストにも到達していませんでした。止まっていたのは composer install です。
症状
ログを見ると、composer install がlockと composer.json の不一致を検出し、exit code 4で止まっていました。
PRの差分を見ると、変わっていたのは composer.json だけで、composer.lock は手つかずでした。exit code 4はComposerの Installer::ERROR_LOCK_FILE_INVALID で、lockと composer.json が食い違っているときに返る値です。コードや型の問題ではありません。
原因
依存関係を並べるとこうなっていました。

据え置かれた pest-plugin-laravel v4.1.0は pestphp/pest ^4.4.1 を要求します。pestだけをv5に上げると、この制約を満たす組み合わせが存在しません。Renovateはlockの再生成に失敗し、composer.json の差分だけをPRに載せていました。
Renovateはpestを上げるとき composer update pestphp/pest --with-dependencies 相当で依存を解決します。名前からして関係するパッケージを一緒に動かしてくれるものだと思っていましたが、動かせる範囲は狭いです。
-w, --with-dependencies Update also dependencies of packages in the argument list, except those which are root requirements.
—
composer update --help(Composer 2.8.3)
(引数に渡したパッケージの依存も更新する。ただしルートの要求に含まれるものは除く。)
動かせるのは「pestが依存しているパッケージ」のうち、ルートの composer.json に書かれていないものだけです。pest-plugin-laravel はpestに依存される側で、しかもルートの require-dev に直接書いてあります。二重に対象外でした。Renovateの postUpdateOptions に composerWithAll を入れると --with-all-dependencies に変わりますが、こちらが広げるのはルートの要求まで含めた「pestが依存する側」です。pest-plugin-laravel は依存の向きが逆なので、こちらでも動きません。Renovateの設定を変えない限り、こうした直接依存のプラグインは自分で上げることになります。
直し方
pest-plugin-laravel もv5に上げ、pest関連だけでlockを作り直しました。
まず pest-plugin-laravel v5.0.1の要件を確認します。
pestphp/pest ^5.0.1laravel/framework ^13.23.0php ^8.4
ホストもCIもPHP 8.4なので満たせます。composer.json の require-dev は、mainからの差分でこうなりました。
- "pestphp/pest": "^4.7",
- "pestphp/pest-plugin-laravel": "^4.1",
+ "pestphp/pest": "^5.0",
+ "pestphp/pest-plugin-laravel": "^5.0",
pestphp/pest の行はRenovateが上げたもので、pest-plugin-laravel の行が今回足した変更です。
最初は composer require --dev に -W だけを付けて実行しました。lockは作り直せたものの、laravel/framework をはじめ、今回の目的と関係の薄い推移依存(直接requireしていない、依存の依存)までまとめて上がり、差分がレビューできる量ではなくなりました。
lockを戻して --minimal-changes を付け直すと、変更はpest本体、PHPUnit 13系とその付随パッケージ、symfony/console、symfony/process に収まりました。
CIのPHP側と同じ検査をローカルで流し、すべて通りました。コミットは composer.json と composer.lock の2ファイルだけです。
–minimal-changes は何をしているのか
Composerは依存を解決するとき、動かしてよいと判断したパッケージを制約内の最新版にします。-W(--with-all-dependencies)で巻き込まれた推移依存も最新版まで上がるので、1つ上げたつもりがlockの差分が数十パッケージ、ということが起きます。
--minimal-changes(短縮形 -m)は、この「最新版を選ぶ」を「lockに記録済みのバージョンを優先する」に変えます。Composer 2.7.0で update / require / remove に追加されたオプションです。
-m, --minimal-changes During a partial update with -w/-W, only perform absolutely necessary changes to transitive dependencies (can also be set via the COMPOSER_MINIMAL_CHANGES=1 env var).
—
composer update --help(Composer 2.8.3)
(-w/-W を付けた部分更新のとき、推移依存には絶対に必要な変更だけを行う。環境変数 COMPOSER_MINIMAL_CHANGES=1 でも指定できる。)
変わるのは、候補を絞る最後の1箇所です。依存解決では、制約を満たすバージョンが1つとは限りません。複数残った候補からどれを採るかを決めているのがポリシーで、-m はそのポリシーに「優先バージョン」の一覧を持たせます。
一覧を作るのは Installer::createPolicy() です。中身は、更新対象として名指ししたパッケージ(updateAllowList)を除いた、lockに載っているパッケージの現在のバージョンです。
if ($forUpdate && $this->minimalUpdate && $this->updateAllowList !== null && $lockedRepo !== null) {
$preferredVersions = [];
foreach ($lockedRepo->getPackages() as $pkg) {
if ($pkg instanceof AliasPackage || in_array($pkg->getName(), $this->updateAllowList, true)) {
continue;
}
$preferredVersions[$pkg->getName()] = $pkg->getVersion();
}
}
—
src/Composer/Installer.php(Composer 2.8.3)
受け取ったポリシー側は、候補を1つに絞る pruneToBestVersion() でこの一覧を見ます。
if ($this->preferredVersions !== null) {
$name = $pool->literalToPackage($literals[0])->getName();
if (isset($this->preferredVersions[$name])) {
$preferredVersion = $this->preferredVersions[$name];
$bestLiterals = [];
foreach ($literals as $literal) {
if ($pool->literalToPackage($literal)->getVersion() === $preferredVersion) {
$bestLiterals[] = $literal;
}
}
if (\count($bestLiterals) > 0) {
return $bestLiterals;
}
}
}
$operator = $this->preferLowest ? '<' : '>';
—
src/Composer/DependencyResolver/DefaultPolicy.php(Composer 2.8.3)
$literals は「このパッケージのこのバージョン」という候補で、ここに並んでいるのは制約を満たしたものだけです。lockと同じバージョンがその中にあれば、それだけに絞って返します。なければ if を抜けて、$operator から下の通常の経路、つまり最新版を選ぶ側に落ちます。固定ではなく優先というのは、この分岐のことです。
今回の composer require --dev に当てはめると、それぞれの変数の中身はこうなります。
| 変数 | 今回入るもの |
|---|---|
$updateAllowList |
["pestphp/pest-plugin-laravel"]。コマンドラインで名前を渡したものだけ |
$preferredVersions |
それを除いたlock上の全パッケージと、そのlockのバージョン |
$literals |
制約を満たす候補。pestphp/pest の番なら5系の各バージョン |
$preferredVersion |
$preferredVersions['pestphp/pest']、つまりlockにあった4系のバージョン |
名指ししたのはプラグインだけなので、pestphp/pest は $preferredVersions に残ります。ところが4系のバージョンは ^5.0.1 を満たさず候補にないので、$bestLiterals は空のまま if を抜け、5系の最新が選ばれます。PHPUnitと symfony/console も同じで、lockのバージョンでは新しい制約を満たせずに候補から外れました。触られなかった大多数は、lockのバージョンが候補に残っていたので、そのまま返っています。
そもそも pestphp/pest が動いてよい側にいるのは -W を付けたからです。動かしてよいかを決めるのが -w / -W で、動かすときにどのバージョンを採るかを決めるのが -m。役割が分かれています。
createPolicy() の条件に updateAllowList があるとおり、2.8.3で効くのはパッケージを指定した部分更新のときだけで、引数なしの composer update には効きません。ここは2.9.0で変わり、全体更新でも制約を満たすのに必要なパッケージだけを動かせるようになっています。
使いどころと注意点
効くのは、上げたいものが決まっていて、それ以外を動かしたくない場面です。
- セキュリティ修正で1パッケージだけ上げたいとき
- まとめて上げると壊れるのが怖い規模のプロジェクト
- CIやRenovateのPRで、lockの変更を意図した範囲に収めたいとき
逆に、これを常用すると推移依存が古い枝に取り残されていきます。定期的に通常の composer update で全体を追従させるのとセットで使うのが健全だと思います。Composer 2.7.0未満には存在しないフラグなので、CIで使うなら先に composer --version を確認しておきます。Renovate自身は、composerの制約が2.7以上なら--minimal-changes を付けてlockを更新します。付け忘れるのは、手で composer require を打つ側です。
pest系をRenovateで1つのPRにまとめる
今回の詰まりは、本体と直接依存のプラグインが別々のPRに分かれたことに尽きます。pest系を1つのグループにまとめれば、同じ失敗はほぼ起きません。
{
"packageRules": [
{
"matchPackageNames": ["pestphp/*"],
"groupName": "pest"
}
]
}
pestphp/* にしておけば、pest-plugin-laravel 以外のプラグインを入れていても一緒に拾われます。
もう1つ、Renovateのブランチに人がコミットすると、以後Renovateはそのブランチを自動更新しなくなります。ドキュメントに書かれた動きで、再開させるにはPR本文のrebaseチェックボックスをONにするか rebase ラベルを付けます。ただしrebaseはRenovateがコミットを作り直す操作なので、手で入れた pest-plugin-laravel の変更は捨てられます。編集したブランチは人がマージするか、閉じてRenovateに作り直させるかのどちらかだとドキュメントも書いているので、このPRはこのままマージまで持っていきます。
まとめ
composer update --with-dependenciesはルートに書かれた依存を動かさない。本体と一緒に上げるべき直接依存のプラグインは自分で上げる- 依存更新のPRが落ちたら、コードの問題か依存解決の問題かを先に切り分ける。exit code 4とlock不一致のメッセージが出ていたら後者を疑う
--minimal-changesは、-Wで巻き込まれる推移依存にlockのバージョンを優先させる。今回はlockの差分がpest・PHPUnit・symfony数個に収まった- ただし常用すると推移依存が古いまま残るので、通常の
composer updateと組み合わせる - プラグインを持つパッケージはRenovateでグループ化しておく
-W を打つ前に -m を思い出す。次の依存更新からはそこから始めます。