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.
-
Można na przykład wygenerować plik zawierający definicje zmiennych, który następnie będzie source’owany wewnątrz skryptu gen-in. ↩