NuGetでバージョンを付けるときに注意すること

# はじめに

[opentelemetry-dotnet](https://github.com/open-telemetry/opentelemetry-dotnet) の拡張パッケージで、 [1.0.0-rc10](https://github.com/open-telemetry/opentelemetry-dotnet/releases/tag/1.0.0-rc10) が非公開になり、代わりに [1.0.0-rc9.1](https://github.com/open-telemetry/opentelemetry-dotnet/releases/tag/1.0.0-rc9.1) がリリースされた。

経緯と対処について、 [opentelemetryのissue](https://github.com/open-telemetry/opentelemetry-dotnet/issues/2991#issuecomment-1083242602) に記載があるが、自分のメモとして解説していこうと思う。
なお、この記事では [NuGet.Versioning](https://www.nuget.org/packages/NuGet.Versioning) の執筆時点の安定版である6.1.0をベースに記述する。

# 何が起きたか

1. ZPagesエクスポーター、各種個別アクティビティの収集等、opentelemetry-dotnetのコア以外のパッケージの1.0.0-rc10が公開された
2. ところが、新規にdotnet add packageしようとしたり、nugetorgのページを見ると、一つ前のリリースである1.0.0-rc9が引き続きrc系の最新版として認識される状態になっていた
    (これは後述するNuGetのバージョン解釈に起因するものとなる)
3. このままでは良くないということで、急遽バージョンだけ変えた1.0.0-rc9.1がリリースされた
4. 無事1.0.0-rc9.1が最新版として認識されるようになった
5. 1.0.0-rc10としてリリースされたものはNuGetパッケージからunlistされ、既にrc10を入れた人以外は入れられないようにされた

# なぜ起こったか

NuGetはバージョン表記のフォーマットとして [SemanticVersioning 2.0.0(semver2)](https://semver.org/lang/ja/) +若干の独自ルールを採用している。
その中のルールとして、

* 大きく最初の"-"で分ける
    * 前半は `[メジャー].[マイナー].[パッチ].[リビジョン]` として解釈する
        * 数字以外が混じっていたらパースエラー
        * 空白は削除される
        * 数値として解釈できる最大値は `int.MaxValue` まで
        * "1.2.3.4.5"のように、五つ以上の区切りがある場合はパースエラーとして扱う
        * メジャーより後ろは省略可能で、省略されたときは0として扱われる
        * **オリジナルのsemver2ではパッチバージョンまでなので注意**
            * semver2では"1.2.3.4"は厳密には書式違反
    * 後半はプレリリース識別子として処理される
        * 任意の英数字と"-"が使用可能(空白不可)で、"."を区切りとして使う
* プレリリース識別子は"."で分割される
    * 使える数に制限はないが、常識的な長さにすること
* それぞれ、10進数でパースできる場合は数字として、できない場合は文字列として解釈する
    * 実装上の制約として数値で解釈できるのは `int.MaxValue` まで
    * 片方しか数値として解釈できない場合は、数値として解釈できる方が古いとみなす
        * semver2の仕様より
        * `1.0.0-1` より `1.0.0-alpha` の方が新しい
* プレリリース識別子がある場合と無い場合とでは、無い場合の方を最新とみなす
    * `1.0.0-alpha` と `1.0.0` では `1.0.0` の方が新しい
* プレリリース識別子の比較で同じものが続いた場合、長い方が新しいとみなす
    * `1.0.0-alpha.2` と `1.0.0-alpha.2.1` では、最後の `.1` の部分があるので `1.0.0-alpha.2.1` を新しいとみなす(`+`を付与することで、それ以降の文字列をコミットハッシュ等の参考情報として付記できる仕組みがあるが、比較には使われない)

というものがある。
NuGetは、バージョンの大小比較の時に、

1. メジャー
2. マイナー
3. パッチ
4. リビジョン

の順に比較し、更にそれらが全て同じ場合は、プレリリース識別子を左から順に比較していく。
この時、数値同士なら数値比較、そうでない場合は文字列の辞書順比較となる。
同じものならば更に次のリリース識別子の比較に進む。

以上のルールを踏まえると、"1.0.0-rc9"と"1.0.0-rc10"及び"1.0.0-rc9.1"は以下のように分解される

|元表記|メジャー|マイナー|パッチ|プレリリース識別子1|プレリリース識別子2|
|-----|-------|-------|------|-------------|------------|
|1.0.0-rc9|1|0|0|rc9||
|1.0.0-rc10|1|0|0|rc10||
|1.0.0-rc9.1|1|0|0|rc9|1|

上記から、"rc9"と"rc10"は文字列比較され、"rc9"の方が辞書的に後の方に来るので、"rc9"が最新と判断される。
ちなみに"1.0.0-rc.10"とすると、"rc"と"rc9"の比較になり、これまた"rc9"が最新と判断されてしまう。
"1.0.0-rc9.1"ならば、"rc9"までは同じだが、最後に"1"が片方に追加されているので、"1.0.0-rc9.1"の方が最新と判断される。

比較手順の詳細は [NuGet.Versioningのソースが参考になる](https://github.com/NuGet/NuGet.Client/blob/6.1.0.106/src/NuGet.Core/NuGet.Versioning/VersionComparer.cs) 。
また、NuGetの実装の要求仕様は [semver2の仕様](https://semver.org/lang/ja/#%E3%82%BB%E3%83%9E%E3%83%B3%E3%83%86%E3%82%A3%E3%83%83%E3%82%AF-%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%8B%E3%83%B3%E3%82%B0%E4%BB%95%E6%A7%98%E6%9B%B8-semver) に準拠しているので、こちらを見ても良い。

# どうすれば防げたか

今回の現象はプレリリース識別子部分が"rc9"部分が辞書順で比較されたため起こったことなので、 `[メジャー].[マイナー].[パッチ]-rc.n` のように、"rc"で一旦区切ってその後に数字を入れれば防げたことになる(参考までに、ベースライブラリや"Microsoft.*"パッケージはそのようにしている)。

# 終わりに

プレリリースの時のタグで二桁以上行くことはあまりないかもしれないが、もしそのようなパッケージを作る場合は気を付けなければならない。
今回検証したコードは https://gist.github.com/itn3000/7f684d75561451fc679b78c39a8b7c23 に置いておいた。