Generacja wersji

Wiele projektów open-source’owych wersjonuje się poprzez ustawienie na stałe w jakimś pliku (config.h, __version__.py, …) numerka, który jest podbijany przy okazji wydania nowej wersji. Nie podoba mi się takie podejście. Przede wszystkim zbędnie zaśmieca historię commitami mającymi niewiele wspólnego z faktycznymi zmianami w logice kodu. Poza tym nie dostarcza wystarczającej informacji o używanej wersji, która jest niezbędna np. przy zgłaszaniu błędów - jest to szczególnie uciążliwe gdy program wydawany jest stosunkowo rzadko.

Według mnie o wiele lepszym sposobem jest generacja wersji na podstawie informacji zwracanej z systemu kontroli wersji. Eliminujemy w ten sposób wady wymienione w powyżej: numery wydań są po prostu kolejnymi tagami, a dowolny moment z historii programu można przedstawić przez dopisanie hasha commita, z którego pochodzi kod źródłowy. Taka jednoznaczna identyfikacja umożliwia developerom m.in. szybki checkout danej wersji programu.

gen-version

Osobiście już w kilku swoich projektach z powodzeniem wykorzystuję poniższy skrypt, nazwijmy go “gen-version”:

#!/bin/sh

version=""
fallback="0.x-unknown"

nl='
'

if [ -f .version ]; then
    version=`cat .version` || version=""
fi

if [ "$version" = "" ]; then
    if [ -d .git ]; then
        version=`git describe --abbrev=4 --dirty --always --tags`
    fi
fi

if [ "$version" = "" ]; then
    version=$fallback
fi

echo $version | tr -d "$nl"

Jeśli użytkownik nie pozbył się katalogu .git, wywołanie powyższego skryptu powinno dać np. następujący rezultat:

0.1.0-4-g977f
┗━━━━━┿━┿━━━━━━ nazwa ostatniego taga
      ┗━┿━━━━━━ ilość commitów po tagu 0.1.0
        ┗━━━━━━ hash bieżącego commita

Nadmienię tylko, że w przypadku gdy w repozytorium nie istnieje choćby jeden tag, skrypt zwróci po prostu hash bieżącego commita, dlatego ze względów estetycznych warto w miarę szybko wydać pierwszą wersję programu. Tak czy siak, wyjście skryptu można przekleić do dowolnej komendy gitowej (git log, git show, …).

Skrypt ten poszukuje również pliku .version, który zawiera wygenerowany przy wcześniejszym przebiegu numer wersji. O szczegółach związanych z tym plikiem powiem pod koniec.

gen-in

Oczywiście tak wygenerowaną wersję musimy przekazać do naszego programu. Możemy to zrobić na wiele sposobów - na przykład dopisując do wywołąnia kompilatora odpowiednią definicję preprocesora, którą wykorzystamy wewnątrz kodu źródłowego:

$ gcc -DVERSION=$(./gen-version) main.cpp -o prog

Nie wszystkie języki programowania to umożliwiają, więc często nie unikniemy po prostu generacji pewnych plików zawierających stałe globalne naszego programu. Na przykład wiele programów napisanych w C trzyma pewne definicje w pliku config.h. Moglibyśmy go generować z pliku wejściowego config.h.in, ale jesteśmy sprytnymi programistami i nie chcemy aby przy każdej zmianie tego pliku rekompilowało się pół projektu (gdyż jest wielce prawdopodobne, że wiele różnych plików będzie miało do niego zależności), to zadeklarujemy w nim tylko zmienne, których definicje znajdą się w pliku config.c. Ten zaś wygenerujemy z config.c.in.

// config.h

#ifndef CONFIG_H
#define CONFIG_H

extern const char* prog_name;
extern const char* version;

#endif
// config.c.in

#include "config.h"

const char* prog_name = "@PACKAGE_NAME@";
const char* version = "@VERSION@";

W powyższym podmienimy dwa napisy: @PACKAGE_NAME@ oraz @VERSION@. Jak? Najprościej będzie po prostu przejechać po tym pliku sedem. Całość umieścimy w jeszcze jednym skrypcie, który nazwiemy “gen-in”:

#!/bin/bash

if [[ $# -ne 1 ]]; then
    echo "usage: gen-in FILE" >&2
    exit 1
fi

in_file="$1"
script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

app_name=${MYAPP_APP_NAME:=Cool Program}
version=${MYAPP_VERSION:=$($script_dir/gen-version)}

sed -e "s|@PACKAGE_NAME[@]|${app_name}|g" \
    -e "s|@VERSION[@]|${version}|g" \
    $in_file

Jeśli takie jest nasze życzenie, skrypt może użyć danych zdefiniowanych w zmiennych środowiskowych ($MYAPP_APP_NAME oraz $MYAPP_VERSION). Jeśli takowe nie istnieją, użyte zostaną wartości domyślne - kolejno jakaś predefiniowana nazwa programu oraz wyjście skryptu gen-version. Dodanie kolejnych nazw do podmiany w plikach wejściowych nie powinno nastręczać większych trudności - wystarczy podążać za przykładami powyżej.

Wywołanie skryptu jest następujące:

$ [opcjonalne zmienne] gen-in config.cpp.in > config.cpp

W jego wyniku powinniśmy otrzymać na przykład następujący plik config.c:

// config.c

#include "config.h"

const char* prog_name = "Cool Program";
const char* version = "0.1.0-4-g977f";

Automatyzacja

Żeby życie nabrało nieco smaku, całość możemy zamknąć w następujących regułach pliku Makefile:

# Jeśli kopiujesz tego Makefile'a, pamiętaj o zamianie wcięć ze spacji na
# tabulacje.

GEN_FILES = config.c
SRCS = $(GEN_FILES) main.c
OBJS = $(SRCS:.c=.o)

CC = gcc
GEN_PRG = gen-in

app = prog

all: $(app)

$(app): $(OBJS)
    $(CC) -o $@ $^

%.o: %.c
    $(CC) -c $<

%.c: %.c.in
    $(GEN_PRG) $< > $@

Jedna uwaga do powyższego: jeśli pokusimy się np. o obsługę tworzenia archiwum dystrybucyjnego (czyli dodamy target dist), to należy pamiętać, że użytkownik tego archiwum nie będzie miał dostępu do metadanych systemu kontroli wersji. Przyjęło się bowiem, że po rozpakowaniu takiego archiwum do dyspozycji użytkownika oddany zostanie kompletny, wygnerowany build system.

Wobec tego musimy załączyć pliki utworzone w wyniku generacji (a więc plik config.c - ale nie config.c.in). Gdyby wewnątrz archiwum znalazł się choć jeden plik *.in, to Make zakończyłby się niepowodzeniem, gdyż skrypty generacyjne (o ile zostałyby dołączone do archiwum) nie byłyby w stanie pobrać z gita informacji o nazwie taga (gdyż - jeszcze raz - nie jesteśmy już wewnątrz repo gitowego).

W nietrywialnych projektach może jednak zajść sytuacja, że Makefile będzie musiał z jakichś powodów wywołać skrypt gen-in. Co wtedy zrobić?

W takim przypadku najczęściej budowanie programu rozbija się na dwa, a nawet trzy etapy (szczególnie jeśli używamy autotools jako generatora build systemu). Powiedzmy, że pierwszym etapem byłoby uruchomienie przez developera skryptu “autogen”, który przygotowałby system budowania. Drugim etapem byłoby wywołanie komendy make dist, która spakowałaby wszystkie niezbędne pliki do archiwum. Następnie użytkownik, który otrzymałby tak przygotowane archiwum musiałby już jedynie wywołać komendę make i ewentualnie make install.

Rozwiązań tutaj jest wiele1, lecz według mnie najczystsze jest jest utworzenie przez skrypt autogen pliku, nazwijmy go “.version”, który będzie zawierał numer wersji z czasu przygotowywania archiwum dystrybucyjnego. Skrypt gen-version jest napisany w taki sposób, że w pierwszym kroku poszukuje tego pliku i jeśli go napotka to zwóci jego zawartość. W praktyce skrypt autogen będzie zawierał następującą komnendę:

#!/bin/sh

rm -f .version && ./gen-version > .version

Należy pamiętać, że plik .version musi zostać dołączony do archiwum. Sam skrypt autogen z reguły nie jest rozprowadzany poza systemem kontroli wersji.

Przy zastosowaniu tej metody nasze archiwum może (a nawet powinno!) zawierać szablony *.in, gdyż generator wersji nie będzie próbował pobierać danych z gita. Unikamy w ten sposób również takich niuansów jak generacja Makefile’a, czy pozostawienie w nim niepoprawnych sekcji.

W powyższym wywodzie celowo nie uwzględniłem plików służących do konfiguracji projektu: “configure”. Pobudka jest czysto egoistyczna: nie jestem ich fanem, ponieważ z reguły zawierają tysiące linii dziwnego, nahackowanego, powiązanego cienkim sznurkiem kodu, o którym nikt nie wie jak działa.


  1. Można na przykład wygenerować plik zawierający definicje zmiennych, który następnie będzie source’owany wewnątrz skryptu gen-in. ↩︎