diff --git a/.gitignore b/.gitignore index 2d73969..dbf1c80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ ./build/* .aider* *~ + +# Generated by configure_file into the build tree. include/ precedes the build +# tree on the include path, so a stray copy here would silently shadow the real +# one and pin every consumer to whatever version it was generated at. +include/akgl/version.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b34a016..c451616 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,8 @@ cmake_minimum_required(VERSION 3.10) -project(akgl LANGUAGES C) +# The single source of truth for the library version. It drives the generated +# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the +# Version field in akgl.pc. Bump it here and nowhere else. +project(akgl VERSION 0.1.0 LANGUAGES C) include(CTest) option(AKGL_COVERAGE "Instrument libakgl and generate coverage reports with CTest" OFF) @@ -78,6 +81,8 @@ set(exec_prefix "\${prefix}") set(libdir "\${exec_prefix}/lib") set(includedir "\${prefix}/include") configure_file(${CMAKE_CURRENT_SOURCE_DIR}/akgl.pc.in ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc @ONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/include/akgl/version.h.in + ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h @ONLY) # Tests use both relative paths and SDL_GetBasePath(), so stage fixtures beside # test executables in every out-of-tree build. @@ -112,9 +117,26 @@ add_library(akgl SHARED src/staticstring.c src/tilemap.c src/util.c + src/version.c ${GAMECONTROLLERDB_H} ) +# While the major version is 0 the ABI is not stable across minor releases, so +# the soname carries major.minor -- libakgl.so.0.1. A plain SOVERSION 0 would +# claim 0.1.0 and 0.2.0 are interchangeable, which is exactly the silent +# mispairing the soname is here to prevent. At 1.0.0 this becomes the major +# alone, matching libakerror. +if(PROJECT_VERSION_MAJOR EQUAL 0) + set(AKGL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}") +else() + set(AKGL_SOVERSION "${PROJECT_VERSION_MAJOR}") +endif() + +set_target_properties(akgl PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION ${AKGL_SOVERSION} +) + add_library(akgl::akgl ALIAS akgl) add_executable(charviewer util/charviewer.c) @@ -137,6 +159,7 @@ set(AKGL_TEST_SUITES staticstring tilemap util + version ) foreach(suite IN LISTS AKGL_TEST_SUITES) @@ -155,6 +178,9 @@ set_tests_properties( target_include_directories(akgl PUBLIC include/ deps/semver/ + # akgl/version.h is generated by configure_file, so it lives in the build tree + # rather than beside the headers it is included from. + ${CMAKE_CURRENT_BINARY_DIR}/include/ ) if(AKGL_COVERAGE) @@ -285,8 +311,8 @@ if(Python3_FOUND) ) endif() -set(main_lib_dest "lib/akgl-${MY_LIBRARY_VERSION}") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/") +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/") install(TARGETS akgl DESTINATION "lib/") install(FILES "deps/semver/semver.h" DESTINATION "include/") install(FILES "include/akgl/actor.h" DESTINATION "include/akgl/") diff --git a/include/akgl/game.h b/include/akgl/game.h index 0d25d18..b21dd8c 100644 --- a/include/akgl/game.h +++ b/include/akgl/game.h @@ -12,8 +12,9 @@ #include "tilemap.h" #include "renderer.h" #include "physics.h" - -#define AKGL_VERSION "0.1.0" +// AKGL_VERSION used to be defined here by hand, which is how akgl.pc came to +// ship an empty Version field: nothing tied the two together. +#include #define AKGL_GAME_AUDIO_TRACK_BGM 1 #define AKGL_GAME_AUDIO_MAX_TRACKS 64 diff --git a/include/akgl/version.h.in b/include/akgl/version.h.in new file mode 100644 index 0000000..2b8f1a9 --- /dev/null +++ b/include/akgl/version.h.in @@ -0,0 +1,56 @@ +/** + * @file version.h + * @brief Declares the libakgl version, both as compiled against and as linked. + * + * GENERATED FILE -- edit include/akgl/version.h.in, never the copy in the build + * tree. Every value here comes from the project() call in CMakeLists.txt, which + * is also what sets the shared library's VERSION and SOVERSION and the Version + * field in akgl.pc. One number, one place, so the header, the soname and + * pkg-config cannot drift apart. + * + * AKGL_VERSION is the version you *compiled against*. akgl_version() reports the + * version of the libakgl you actually *linked*. They disagree when a stale + * shared library is ahead of the new one on the loader path -- the failure the + * soname exists to prevent and this pair exists to diagnose. + */ + +#ifndef _AKGL_VERSION_H_ +#define _AKGL_VERSION_H_ + +#define AKGL_VERSION "@PROJECT_VERSION@" +#define AKGL_VERSION_MAJOR @PROJECT_VERSION_MAJOR@ +#define AKGL_VERSION_MINOR @PROJECT_VERSION_MINOR@ +#define AKGL_VERSION_PATCH @PROJECT_VERSION_PATCH@ + +/** + * @brief True when the headers on the include path are at least the given version. + * + * For consumers that must build against more than one libakgl release. This is + * the test libakstdlib could not write against libakerror, which published no + * version macro and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. + * + * @code + * #if AKGL_VERSION_AT_LEAST(0, 2, 0) + * akgl_something_new(); + * #endif + * @endcode + */ +#define AKGL_VERSION_AT_LEAST(major, minor, patch) \ + ((AKGL_VERSION_MAJOR > (major)) || \ + (AKGL_VERSION_MAJOR == (major) && AKGL_VERSION_MINOR > (minor)) || \ + (AKGL_VERSION_MAJOR == (major) && AKGL_VERSION_MINOR == (minor) && \ + AKGL_VERSION_PATCH >= (patch))) + +/** + * @brief Report the version of the libakgl that is actually linked. + * + * Returns "major.minor.patch". Compare it against AKGL_VERSION to detect a + * stale shared library. Never returns NULL, and the storage is static -- the + * caller must not free or modify it. + * + * This returns a string rather than an akerr_ErrorContext * because it cannot + * fail, the same reason akerr_name_for_status() returns a name directly. + */ +const char *akgl_version(void); + +#endif // _AKGL_VERSION_H_ diff --git a/src/version.c b/src/version.c new file mode 100644 index 0000000..ed6884f --- /dev/null +++ b/src/version.c @@ -0,0 +1,15 @@ +/** + * @file version.c + * @brief Implements the runtime half of the version API. + */ + +#include + +const char *akgl_version(void) +{ + // AKGL_VERSION is baked in when this translation unit is compiled, so what + // comes back is the version of the shared library the caller linked -- not + // the version of the header the caller built against. That asymmetry is the + // whole point: comparing the two detects a stale libakgl on the loader path. + return AKGL_VERSION; +} diff --git a/tests/version.c b/tests/version.c new file mode 100644 index 0000000..8872031 --- /dev/null +++ b/tests/version.c @@ -0,0 +1,108 @@ +/** + * @file version.c + * @brief Unit tests for the version macros and the linked-library accessor. + * + * These assert that the several places the version appears cannot drift: the + * string, the numeric components, and what the shared library reports at + * runtime all come from one project() call, and this is what proves it. + */ + +#include +#include +#include + +#include +#include + +#include "testutil.h" + +/** + * @brief The version string and the numeric components must describe one version. + * + * They are separate substitutions in version.h.in, so a mangled template can + * leave them disagreeing and nothing else would notice. + */ +akerr_ErrorContext *test_version_string_matches_components(void) +{ + PREPARE_ERROR(e); + char assembled[64]; + + ATTEMPT { + snprintf(assembled, sizeof(assembled), "%d.%d.%d", + AKGL_VERSION_MAJOR, AKGL_VERSION_MINOR, AKGL_VERSION_PATCH); + TEST_ASSERT(e, strcmp(assembled, AKGL_VERSION) == 0, + "AKGL_VERSION is \"%s\" but the components assemble to \"%s\"", + AKGL_VERSION, assembled); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The linked library must report the version its headers were generated from. + * + * In this build tree they are the same tree, so this can only fail if the test + * picked up an installed libakgl off LD_LIBRARY_PATH instead of the one just + * built -- which is precisely the mispairing the accessor exists to catch. + */ +akerr_ErrorContext *test_version_linked_matches_compiled(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_ASSERT(e, akgl_version() != NULL, "akgl_version returned NULL"); + TEST_ASSERT(e, strcmp(akgl_version(), AKGL_VERSION) == 0, + "linked libakgl reports \"%s\" but the headers say \"%s\"", + akgl_version(), AKGL_VERSION); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief AKGL_VERSION_AT_LEAST must order versions correctly at the boundaries. + * + * Checked against the current version rather than fixed literals, so the test + * does not have to be rewritten every time the version is bumped. + */ +akerr_ErrorContext *test_version_at_least_boundaries(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_ASSERT(e, + AKGL_VERSION_AT_LEAST(AKGL_VERSION_MAJOR, AKGL_VERSION_MINOR, AKGL_VERSION_PATCH), + "AKGL_VERSION_AT_LEAST rejected the current version"); + TEST_ASSERT(e, + !AKGL_VERSION_AT_LEAST(AKGL_VERSION_MAJOR, AKGL_VERSION_MINOR, AKGL_VERSION_PATCH + 1), + "AKGL_VERSION_AT_LEAST accepted a later patch"); + TEST_ASSERT(e, + !AKGL_VERSION_AT_LEAST(AKGL_VERSION_MAJOR, AKGL_VERSION_MINOR + 1, 0), + "AKGL_VERSION_AT_LEAST accepted a later minor"); + TEST_ASSERT(e, + !AKGL_VERSION_AT_LEAST(AKGL_VERSION_MAJOR + 1, 0, 0), + "AKGL_VERSION_AT_LEAST accepted a later major"); + TEST_ASSERT(e, + AKGL_VERSION_AT_LEAST(AKGL_VERSION_MAJOR, AKGL_VERSION_MINOR, 0), + "AKGL_VERSION_AT_LEAST rejected an earlier patch"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_error_init()); + CATCH(errctx, test_version_string_matches_components()); + CATCH(errctx, test_version_linked_matches_compiled()); + CATCH(errctx, test_version_at_least_boundaries()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +}