← Blog一覧へ

Renovateのpest v5更新は、プラグインを置き去りにしてlockを作れなかった

#tech#Renovate#Composer#Pest

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 が食い違っているときに返る値です。コードや型の問題ではありません。

原因

依存関係を並べるとこうなっていました。

composer.jsonのrequire-devにある依存関係の図。pestphp/pestはRenovateが^4.7から^5.0に上げた。pestphp/pest-plugin-laravelは^4.1のまま据え置き。そのv4.1.0がpestphp/pest ^4.4.1を要求しており、ここで^5.0とぶつかる。pestphp/pestの^5.0と^4.4.1は両立しない。

据え置かれた 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の要件を確認します。

ホストも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で変わり、全体更新でも制約を満たすのに必要なパッケージだけを動かせるようになっています。

使いどころと注意点

効くのは、上げたいものが決まっていて、それ以外を動かしたくない場面です。

逆に、これを常用すると推移依存が古い枝に取り残されていきます。定期的に通常の 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はこのままマージまで持っていきます。

まとめ

-W を打つ前に -m を思い出す。次の依存更新からはそこから始めます。