diff --git a/CMakeLists.txt b/CMakeLists.txt index d46ab619f..a304319af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ option(SILKIT_BUILD_DEMOS "Build the SIL Kit Demos" ON) option(SILKIT_BUILD_STATIC "Compile the SIL Kit as a static library" OFF) option(SILKIT_BUILD_TESTS "Enable unit and integration tests for the SIL Kit" ON) option(SILKIT_BUILD_UTILITIES "Build the SIL Kit utility tools" ON) +option(SILKIT_BUILD_GENERATE_VERSION "Build the sil-kit-generate-version maintainer tool" ON) option(SILKIT_BUILD_DOCS "Build documentation for the SIL Kit (requires Doxygen and Sphinx)" OFF) option(SILKIT_INSTALL_SOURCE "Install and package the source tree" OFF) option(SILKIT_ENABLE_COVERAGE "Enable coverage for builds (requires gcc, clang)" OFF) diff --git a/SilKit/ci/Jenkinsfile b/SilKit/ci/Jenkinsfile deleted file mode 100755 index 83aa024fd..000000000 --- a/SilKit/ci/Jenkinsfile +++ /dev/null @@ -1,636 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -// vim: set ft=groovy: - -//################# -// Global Variables -//################# - -// pipeline config -projectName="vib-main" -artifactName='SilKit' -keepNumBuilds=10 //number of log files on Jenkins, workspaces are always cleaned -publishToArtifactory=false -globalBuildDebugBin=false //debug binaries are only build when doing a release to customers -buildWarningsAsErrors=true // compiler warnings are treated as errors -// artifactory for archiving / packaging -artifactoryServer = Artifactory.server('IntegrationBusArtifactory') -artifactoryBuildInfo = Artifactory.newBuildInfo() - -def gitBranch="main-mirror" // overriden in parallel pipeline based on scm vars - -def buildConfigs = [ - // Windows builds: - [ - - Name: "VS2017-Win64", - NodeLabel: "ninja && buildtools2019", - Arch:"x64", - MsvcVersion:"14.1", - PublishArtifacts: true, - BuildDocs: true, - CmakePreset: "vs141-x64-release", - ] - ,[ - Name: "VS2017-Win32", - NodeLabel: "ninja && buildtools2019", - Arch:"x64_x86", - MsvcVersion:"14.1", - PublishArtifacts: true, - BuildDocs: true, - CmakePreset: "vs141-x86-release", - ] - - // Linux builds: - ,[ - Name: "Ubuntu-18.04-clang", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=18.04", - PublishArtifacts: false, - CmakePreset: "clang10-release", - ] - ,[ - Name: "Ubuntu-18.04-gcc", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=18.04", - PublishArtifacts: true, - BuildDocs: true, - CmakePreset: "gcc8-release", - TriggerAbiCheck: true, - ] - ,[ - Name: "Ubuntu-20.04-gcc-10", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=20.04", - PublishArtifacts: false, - CmakePreset: "gcc10-release", - ] - ,[ - Name: "Ubuntu-20.04-clang-12", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=20.04", - PublishArtifacts: false, - CmakePreset: "clang12-release", - ] - ,[ - Name: "Ubuntu-22.04-gcc-12", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", - PublishArtifacts: false, - CmakePreset: "gcc12-release", - ] - ,[ - Name: "Ubuntu-22.04-clang-14", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", - PublishArtifacts: false, - CmakePreset: "clang14-release", - checkLicenses: true, - ] - // Sanitizer builds: - ,[ - Name: "Ubuntu-22.04-clang-14-thread-sanitizer", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", - PublishArtifacts: false, - CmakeArgs: "-D SILKIT_ENABLE_THREADSAN=ON -D SILKIT_BUILD_DASHBOARD=OFF", - CmakePreset: "clang14-release", - TestDebug: true, - ] - ,[ - Name: "Ubuntu-22.04-clang-14-address-sanitizer", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", - PublishArtifacts: false, - CmakeArgs: "-D SILKIT_ENABLE_ASAN=ON -D SILKIT_BUILD_DASHBOARD=OFF", - CmakePreset: "clang14-release", - TestDebug: true, - ] - ,[ - Name: "Ubuntu-22.04-clang-14-undefined-behavior-sanitizer", - NodeLabel: "docker && linux", - DockerImage: "silkit-ubuntu", - DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", - PublishArtifacts: false, - CmakeArgs: "-D SILKIT_ENABLE_UBSAN=ON -D SILKIT_BUILD_DASHBOARD=OFF", - CmakePreset: "clang14-release", - TestDebug: true, - ] -] - - -//########################################## -// Utilities -//########################################## -@NonCPS -def isFullBuildRequired(branchName) { - // Master and Pull Requests are always full builds - if(branchName == 'main-mirror' || branchName.startsWith("PR-")) { - return true; - } - // A user requests a fullbuild by adding a "-full" suffix - if(branchName.contains("-full")) { - return true; - } - return false -} - -@NonCPS -def isDockerNode(label) { - return label.toUpperCase().contains('DOCKER') -} - -@NonCPS -def isClangBuild(label) { - return label.toUpperCase().contains('CLANG') -} - -def setBuildRetention(bi) { - if(gitBranch == 'main-mirror' || gitBranch == 'origin/main-mirror') { - bi.retention(maxBuilds: 40, deleteBuildArtifacts: true, async: true) - } else { - bi.retention(maxBuilds: 2, maxDays: 50, deleteBuildArtifacts: true, async: true) - } -} - -//recursive submodule git checkout -def checkoutGit() { - return checkout([$class: 'GitSCM', - branches: scm.branches, - doGenerateSubmoduleConfigurations: false, - extensions: [ - [$class: 'SubmoduleOption', - disableSubmodules: false, - parentCredentials: false, - recursiveSubmodules: true, - reference: '', - trackingSubmodules: false]], - submoduleCfg: [], - userRemoteConfigs: scm.userRemoteConfigs - ]) -} -def shallowCheckoutGit() { - return checkout([$class: 'GitSCM', - branches: scm.branches, - doGenerateSubmoduleConfigurations: false, - userRemoteConfigs: scm.userRemoteConfigs - ]) -} - -def archiveToArtifactory(pattern) { - if (! publishToArtifactory ) { - print("INFO: skipping publishing to artifactory because of user request") - return - } - - configFileProvider( - [configFile(fileId: 'SILKIT_ARTIFACTORY_REPO', variable: 'SILKIT_ARTIFACTORY_REPO')]) { - def repo = readFile(file: SILKIT_ARTIFACTORY_REPO).trim() - print("Artifactory: archiving with regexp file spec pattern=${pattern}") - spec = """{ - "files": [{ - "pattern": "${pattern}", - "regexp" : "true", - "target" : "${repo}/${projectName}/${env.GIT_BRANCH}/${env.GIT_COMMIT}/", - "props" : "git_commit=${env.GIT_COMMIT};git_branch=${env.GIT_BRANCH};git_url=${env.GIT_URL}" - }]}""" - print("artifactory spec=${spec}") - def buildInfo = artifactoryServer.upload( - spec: spec, - failNoOp: true - ) - setBuildRetention(buildInfo) - artifactoryBuildInfo.append(buildInfo) - } -} - -def buildDocker(config) { - stage("Building Docker Image") { - def dockerImage = config["DockerImage"] - def dockerDir = "./SilKit/ci/docker" - def buildArgs = "--build-arg ARTIFACTORY=${artifactoryServer.url} " - - configFileProvider([ - configFile(fileId: 'VECTOR_DOCKER_REGISTRY', variable: 'VECTOR_DOCKER_REGISTRY'), - configFile(fileId: 'SILKIT_ARTIFACTORY_REPO', variable: 'SILKIT_ARTIFACTORY_REPO'), - configFile(fileId: 'PROXY_DOMAIN', variable: 'PROXY_DOMAIN'), - configFile(fileId: 'PYPI_MIRROR', variable: 'PYPI_MIRROR'), - ]) { - def registry = readFile(file: VECTOR_DOCKER_REGISTRY).trim() - buildArgs += " --build-arg REGISTRY=${registry} " - - def repo = readFile(file: SILKIT_ARTIFACTORY_REPO).trim() - buildArgs += " --build-arg SILKIT_ARTIFACTORY_REPO=${repo} " - - def proxyDomain = readFile(file: PROXY_DOMAIN).trim() - buildArgs += " --build-arg PROXY_DOMAIN=${proxyDomain} " - - def pypiMirror = readFile(file: PYPI_MIRROR).trim() - buildArgs += " --build-arg PYPI_MIRROR=${pypiMirror} " - - print("Building docker image ${dockerImage}") - if(config.containsKey("DockerBuildArgs")) { - buildArgs += config["DockerBuildArgs"] - } - // We assume the docker file (located in the docker subfolder) is named exactly like the docker image - buildArgs += " -f ${dockerDir}/${dockerImage} ${dockerDir}" - docker.build(dockerImage, buildArgs) - } - } -} -// runOnNode: a helper that runs the userStages closure on an appropriate node -// i.e., this allows running the same stages on docker and windows -def runOnNode(config, userStages) { - def buildName = config["Name"] - def wsDir = "workspace/${env.JOB_NAME}/" \ - + buildName.replaceAll(" ", "_") + "_${env.BUILD_NUMBER}" - return { - node(config["NodeLabel"]) { - ws(wsDir) { - try { - if(isDockerNode(config["NodeLabel"])) { - //the built image should be cached and shared anyway - shallowCheckoutGit() - print("Calling docker inside in ${pwd()}") - buildDocker(config) - docker.image(config["DockerImage"]).inside { - userStages() - } - } else { - print("Working in ${pwd()}") - userStages() - } - } finally { - cleanWs(cleanWhenNotBuilt: true, cleanWhenFailure: true, - cleanWhenSuccess: true, cleanWhenAborted: true, - deleteDirs: true, disableDeferredWipeout: true) - } - } //ws - } //node - } //return -} - -// Canonical way on CMake + Ninja + VS is to source the appropriate vcvarsall script -// before invoking cmake -G Ninja -def getVsBuildEnv(arch, version){ - def localEnv=[] - def toolDir="${tool 'BuildTools2019'}\\..\\..\\.." - // get vcvarsall.bat environment - def envstr = bat returnStdout: true, script: "\"${toolDir}\\VC\\Auxiliary\\Build\\vcvarsall.bat\" ${arch} -vcvars_ver=${version} &set" - def lines = envstr.split("\r\n") - for(int i = 0; i < lines.size(); i++) { - def line = lines[i] - if(line =~ /^\S+=.*/) { - localEnv += line - } - } - return localEnv -} - -def run(what) { - if(isUnix()) { - sh "${what}" - } else { - bat "${what}" - } -} -// quote percent sign for DOS batch, eg. uses in git log --format=%ct -def quoteBat(userStr) { - def res="@" //disable printing the command itself - for(int i = 0; i < userStr.size(); i++) { - def ch = userStr[i] - if( ch == '%' ) { - res += "%%" - } else{ - res += ch - } - } - return res -} -def runWithOutput(what) { - if(isUnix()) { - return sh(script: "${what}", returnStdout: true).trim() - } else { - return bat(script: "${quoteBat(what)}", returnStdout: true).trim() - } -} - -//########################################## -//the actual build process -//########################################## - -// instantiate a build on an appropriate node -def doBuild(Map config) { - return runOnNode(config, { - def buildName = config["Name"] - def buildPreset = config["CmakePreset"] - def debugPreset = buildPreset.replace("-release", "-debug") - def buildCmakeArgs = config.getOrDefault("CmakeArgs", "") - def buildDocs = config.getOrDefault("BuildDocs", false) - def publishArtifacts = config.getOrDefault("PublishArtifacts", false).toBoolean() - def buildTestDebug = config.getOrDefault("TestDebug", false) - def triggerAbiCheck = config.getOrDefault("TriggerAbiCheck", false) - def checkLicenses = config.getOrDefault("checkLicenses", false) - def pipenvExtraArgs = "" - def pythonExe = isUnix() ? "python" : "py -3.9" - - def buildEnv = [] - def scmVars = [:] - - stage("Git checkout and ENV setup (${buildName})") { - if(config.containsKey("MsvcVersion")) { - // visual studio toolset selection via vcvarsall env - buildEnv = buildEnv + getVsBuildEnv(config["Arch"], config["MsvcVersion"]) - buildEnv.add("MSBUILDDISABLENODEREUSE=1") //fixes spurious windows failures - } - - scmVars = checkoutGit() - - // For reproducible build set SOURCE_DATE_EPOCH to time of last commit - def commitTime = runWithOutput("git log --max-count=1 --format=%ct -r origin/main-mirror") - buildEnv.add("SOURCE_DATE_EPOCH=${commitTime}") - buildEnv.add("TZ=UTC") - buildEnv.add("LC_ALL=C.UTF-8") - buildEnv.add("LANG=C.UTF-8") - - print("DEBUG scmVars=${scmVars}, commitTime=${commitTime}, TZ=${env.TZ}, LC_ALL=${env.LC_ALL} buildEnv=${buildEnv}") - - // save branch name for publishing stage - gitBranch = scmVars.GIT_BRANCH - buildEnv.add("GIT_BRANCH=${scmVars.GIT_BRANCH}") - buildEnv.add("GIT_COMMIT=${scmVars.GIT_COMMIT}") - buildEnv.add("GIT_URL=${scmVars.GIT_URL}") - } - - if(checkLicenses) { - stage("License Check (${buildName})") { - run "sh ./SilKit/ci/check_licenses.sh" - } - } - - def warningsAsError = config.getOrDefault("WarningAsErrors", false).toBoolean() - if(warningsAsError) { - print("Enabling Warnings-as-Errors for the current build config") - buildCmakeArgs +=" -DSILKIT_WARNINGS_AS_ERRORS=ON " - } else { - print("Disabling Warnings-as-Errors for the current build config") - } - if(buildDocs) { - buildCmakeArgs +=" -DSILKIT_BUILD_DOCS=ON " - } - if(publishArtifacts || buildDocs) { - buildCmakeArgs +=" -DSILKIT_INSTALL_SOURCE=ON " - } - - if(config.containsKey("MsvcVersion")) { - def msvcVersion = config["MsvcVersion"] - //needed for binary download URL resolution - buildCmakeArgs += " -D MSVC_TOOLSET_VERSION=${msvcVersion} " - } - - withEnv(buildEnv) { - stage("${buildName}: cmake version") { - run("cmake --version") - } - stage("${buildName}: cmake configure for preset ${buildPreset}") { - if(buildDocs) { - // Install dependencies for documentation - configFileProvider([ - configFile(fileId: 'PYPI_MIRROR', variable: 'PYPI_MIRROR'), - ]) { - def pypiMirror = readFile(file: PYPI_MIRROR).trim() - print("Using pipenv to install docs dependencies") - if(!isUnix()) { - run("py -3.9 -m pip install pipenv==2022.9.8") - pipenvExtraArgs = "--python 3.9" - } - run("${pythonExe} -m pipenv ${pipenvExtraArgs} install --pypi-mirror ${pypiMirror} -r SilKit/ci/docker/docs_requirements.txt") - } - } - run("cmake --preset ${buildPreset} ${buildCmakeArgs}") - } - - stage("${buildName}: cmake build for preset ${buildPreset}") { - if(buildDocs) { - run("${pythonExe} -m pipenv ${pipenvExtraArgs} run cmake --build --preset ${buildPreset}") - } else { - run("cmake --build --preset ${buildPreset}") - } - } - - stage("${buildName}: ctest for preset ${buildPreset}") { - timeout(time: 3, unit: 'MINUTES') { - run("ctest --preset ${buildPreset} -R \"^Test\" --output-on-failure") - junit("_build/${buildPreset}/**/*gtestresults.xml") - } - timeout(time: 10, unit: 'MINUTES') { - run("ctest --preset ${buildPreset} -R \"^ITest\" --output-on-failure") - junit("_build/${buildPreset}/**/*gtestresults.xml") - } - } - - if(globalBuildDebugBin || buildTestDebug) { - stage("${buildName}: cmake build for preset ${buildPreset} @Debug") { - // in Debug builds we only need the library and symbols - def debugFlags = "" - debugFlags += " -D SILKIT_BUILD_DOCS=OFF " - debugFlags += " -D SILKIT_INSTALL_SOURCE=OFF " - debugFlags += " -D SILKIT_BUILD_UTILITIES=OFF " - debugFlags += " -D SILKIT_BUILD_DEMOS=OFF " - - run "cmake --preset ${debugPreset} ${debugFlags}" - run "cmake --build --preset ${debugPreset}" - run "cmake --build --preset ${debugPreset} --target package" - } - } - // run tests for sanitizer builds also in debug mode - if(buildTestDebug) { - stage("${buildName}: ctest for preset ${debugPreset}") { - timeout(time: 3, unit: 'MINUTES') { - run("ctest --preset ${debugPreset} -R \"^Test\" --output-on-failure") - junit("_build/${debugPreset}/**/*gtestresults.xml") - } - timeout(time: 10, unit: 'MINUTES') { - run("ctest --preset ${debugPreset} -R \"^ITest\" --output-on-failure") - junit("_build/${debugPreset}/**/*gtestresults.xml") - } - } - } - stage("${buildName}: packaging artifacts for preset ${buildPreset}") { - if(publishArtifacts) { - run "cmake --build --preset ${buildPreset} --target package" - if(globalBuildDebugBin) { - run "python ./SilKit/ci/package.py \"_build/${buildPreset}/${artifactName}-*-Release.zip\" \"_build/${debugPreset}/${artifactName}-*-Debug.zip\"" - archiveToArtifactory("${artifactName}-.*.zip") - } else { - archiveToArtifactory("_build/${buildPreset}/${artifactName}-.*.zip") - } - - // safe the debug symbols files on windows/Linux - archiveToArtifactory("_build/${debugPreset}/${artifactName}-.*-SYMBOLS.zip") - if(!isUnix()) { - archiveToArtifactory("_build/${buildPreset}/${artifactName}-.*-SYMBOLS.zip") - } - } else { - print("Package publishing disabled: config=${config}") - } - } - if (publishArtifacts && triggerAbiCheck) { - stage("${buildName}: Triggering ABI Check") { - def versionList = [] - - // look for the archive file created in the previous step - for (zipFile in findFiles(glob: "**.zip")) - { - def m = zipFile =~ /^.*SilKit-([0-9]+[.][0-9]+[.][0-9]+)-[^\/]*.zip$/ - if (!m) - { - continue - } - - versionList += m[0][1] - } - - def versions = versionList.toSet() - - if (versions.size() == 0) - { - echo "No version found for which the ABI check could be triggered!" - return - } - - if (versions.size() > 1) - { - error "Multiple versions found for which the ABI check would be triggered!" - } - - // take the first artifact we found and extract the version from it - def silKitVersion = versions.first() - echo "Triggering ABI-Check for SIL Kit version ${silKitVersion} (${env.GIT_BRANCH}, ${env.GIT_COMMIT})" - - build( - job: '/SilKit/sil-kit-dev-abi-check/main', - wait: false, - parameters: [ - string(name: 'TEST_BRANCH', value: env.GIT_BRANCH), - string(name: 'TEST_COMMIT', value: env.GIT_COMMIT), - string(name: 'TEST_VERSION', value: silKitVersion) - ] - ) - } - } - } - }) -} - -// node main entry. parallel invocation of all builds, packaging -node { - try { - properties([ - buildDiscarder(logRotator(numToKeepStr: "${keepNumBuilds}")), - parameters([ - booleanParam(name: "ForceFullBuild", defaultValue: false, - description: "Force a full build with artificat uploads") - ,booleanParam(name: "ForceArtifactUpload", defaultValue: false, - description: "Force uploading artifact uploads") - ,booleanParam(name: "UseThreadSanitizer", defaultValue: false, - description: "Build with thread sanitizer, pelase refer to the output logs of tests") - ,booleanParam(name: "UseAddressSanitizer", defaultValue: false, - description: "Build with thread sanitizer, pelase refer to the output logs of tests") - ]) - ]) - - def doFullBuild = isFullBuildRequired(env.BRANCH_NAME) - if(params.ForceFullBuild) { - print("Forcing a full build at user request!") - doFullBuild = true - } - - def configNames = [] - - if(params.UseAddressSanitizer) { - print("Building on Ubuntu with -f sanitizer=thread") - configNames = [ - "Ubuntu-22.04-clang-14-thread-sanitizer" - ] - } - else if (params.UseThreadSanitizer) { - print("Building on Ubuntu with -f sanitizer=address") - configNames = [ - "Ubuntu-22.04-clang-14-address-sanitizer" - ] - } - else if(doFullBuild) { - print("Doing a full build and publishing artifacts on branch ${env.BRANCH_NAME}") - configNames = [ - "VS2017-Win32", - "VS2017-Win64", - "Ubuntu-18.04-gcc", - "Ubuntu-22.04-clang-14", // most modern compiler, no artifacts - "Ubuntu-22.04-clang-14-thread-sanitizer", // TSan with most modern compiler, no artifacts - "Ubuntu-22.04-clang-14-address-sanitizer", // ASan with most modern compiler, no artifacts - "Ubuntu-22.04-clang-14-undefined-behavior-sanitizer", // UBSan with most modern compiler, no artifacts - ] - publishToArtifactory = true - globalBuildDebugBin = true - } else { - print("Doing a minimal build without publishing artifacts on branch ${env.BRANCH_NAME}") - configNames = [ - "VS2017-Win32", - "Ubuntu-22.04-clang-14-thread-sanitizer", // TSan with most modern compiler - "Ubuntu-22.04-clang-14-address-sanitizer", // ASan with most modern compiler - "Ubuntu-22.04-clang-14-undefined-behavior-sanitizer", // UBSan with most modern compiler - ] - } - def builds = [:] - configNames.each { - def configName = it - def buildConfig = buildConfigs.find { it.Name == configName} - if(params.ForceArtifactUpload) { - print("ForceArtifactUpload: PublishArtifacts is enabled for ${configName}") - buildConfig["PublishArtifacts"] = true - globalBuildDebugBin = true - } else { - if(!doFullBuild) { - print("PublishArtifacts is disabled for ${configName}") - buildConfig["PublishArtifacts"] = false - } - } - builds.put(it, doBuild(buildConfig)) - } - - if(params.ForceArtifactUpload) { - print("Forcing Artifact Upload") - publishToArtifactory = true - } - - parallel(builds) - - } - finally { - if(publishToArtifactory) { - stage("Publishing Build Info") { - print("INFO: publishing artifactory build info for branch ${gitBranch}") - setBuildRetention(artifactoryBuildInfo) - artifactoryServer.publishBuildInfo(artifactoryBuildInfo) - } - } else { - stage("Done (No Publishing)") { - print("INFO: not publishing artifacts") - } - } - cleanWs(cleanWhenNotBuilt: true, cleanWhenFailure: true, - cleanWhenSuccess: true, cleanWhenAborted: true, - deleteDirs: true, disableDeferredWipeout: true ) - } -} diff --git a/SilKit/cmake/SilKitVersion.cmake b/SilKit/cmake/SilKitVersion.cmake index 852d55beb..1dd9c2c02 100644 --- a/SilKit/cmake/SilKitVersion.cmake +++ b/SilKit/cmake/SilKitVersion.cmake @@ -3,16 +3,27 @@ # SPDX-License-Identifier: MIT # SIL Kit Versioning: -# * Major and minor release number is configured here. The patch number should not be changed here; it will be set by -# the Jenkins workflow to the master branch's build number for packaging (cmake -SILKIT_BUILD_NUMBER). -# * Major and minor release number, as well as the sprint number are encoded into Version.hpp and compiled into the library, -# they will be accessible from public headers. +# * Major, minor and patch release number are configured here. This is the source of truth: the generated public header +# SilKit/include/silkit/capi/SilKitVersionMacros.h is produced from these numbers and compiled into the library, so +# they are accessible from public headers at runtime. +# * Do not edit the numbers below by hand. Run the sil-kit-generate-version tool, which keeps this file, the generated +# header and the changelog in sync. See docs/development/release.md. +# * SILKIT_BUILD_NUMBER, SILKIT_BUILD_GIT_HASH and SILKIT_VERSION_SUFFIX describe a build, not the source tree. All +# three are build-time overrides passed to the sources as compile definitions; the generated header only carries +# the fallbacks. CI should pass the hash it actually built, so the library reports that commit rather than the +# parent of the version bump, and may set a suffix to mark a pre-release: +# cmake -DSILKIT_BUILD_GIT_HASH= -DSILKIT_BUILD_NUMBER=N -DSILKIT_VERSION_SUFFIX=rc1 +# The suffix also flows into VERSION_STRING below, so CPack package names carry it too. macro(configure_silkit_version project_name) set(SILKIT_VERSION_MAJOR 5) set(SILKIT_VERSION_MINOR 0) set(SILKIT_VERSION_PATCH 8) set(SILKIT_BUILD_NUMBER 0 CACHE STRING "The build number") - set(SILKIT_VERSION_SUFFIX "") + # Not named SILKIT_GIT_HASH: older build trees carry a stale INTERNAL cache + # entry under that name, and set(... CACHE ...) would not overwrite it. + set(SILKIT_BUILD_GIT_HASH "" CACHE STRING + "Git hash of the built sources; empty keeps the one in SilKitVersionMacros.h") + set(SILKIT_VERSION_SUFFIX "" CACHE STRING "Pre-release suffix, e.g. rc1; empty for a normal build") set(VERSION_STRING "${SILKIT_VERSION_MAJOR}.${SILKIT_VERSION_MINOR}.${SILKIT_VERSION_PATCH}") if (SILKIT_VERSION_SUFFIX) diff --git a/SilKit/include/silkit/capi/SilKitVersionMacros.h b/SilKit/include/silkit/capi/SilKitVersionMacros.h new file mode 100644 index 000000000..cc5e80759 --- /dev/null +++ b/SilKit/include/silkit/capi/SilKitVersionMacros.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +// GENERATED FILE - DO NOT EDIT BY HAND. +// Written by the sil-kit-generate-version tool from the version numbers in +// SilKit/cmake/SilKitVersion.cmake. See docs/development/release.md. + +#pragma once + +#define SILKIT_VERSION_MAJOR 5 +#define SILKIT_VERSION_MINOR 0 +#define SILKIT_VERSION_PATCH 8 + +// Everything below describes a build rather than the source tree, so CMake +// supplies it and the values here are only fallbacks: +// -DSILKIT_BUILD_NUMBER=N stamps a build number +// -DSILKIT_BUILD_GIT_HASH= the commit actually built; the fallback is +// the commit that was HEAD when this file was +// generated, i.e. the parent of the bump +// -DSILKIT_VERSION_SUFFIX=rc1 marks a pre-release, which also changes +// SILKIT_VERSION_STRING to "5.0.8-rc1" +#ifndef SILKIT_BUILD_NUMBER +#define SILKIT_BUILD_NUMBER 0 +#endif + +#ifndef SILKIT_GIT_HASH +#define SILKIT_GIT_HASH "b5fa2b126cd9538a0f5889bfffd99d2df2985528" +#endif + +#ifndef SILKIT_VERSION_SUFFIX +#define SILKIT_VERSION_SUFFIX "" +#endif + +#ifndef SILKIT_VERSION_STRING +#define SILKIT_VERSION_STRING "5.0.8" +#endif diff --git a/SilKit/source/CMakeLists.txt b/SilKit/source/CMakeLists.txt index 930af61ad..898c157bf 100644 --- a/SilKit/source/CMakeLists.txt +++ b/SilKit/source/CMakeLists.txt @@ -14,47 +14,6 @@ add_subdirectory(dashboard) find_package(Threads REQUIRED) -# Encode the current GIT and version infos into version_macros.hpp -# If this file is present in the current source directory we assume -# that this source-tree is a non-git/packaged source tree and re-use -# the file unmodified. -set(GIT_DIR "${PROJECT_SOURCE_DIR}/../.git") -set(GIT_HEAD_FILE "${GIT_DIR}/HEAD") -set(VERSION_MACROS_HPP ${CMAKE_CURRENT_BINARY_DIR}/version_macros.hpp) -if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/version_macros.hpp) - message(STATUS "SIL Kit: using deployed version_macros.hpp") - set(VERSION_MACROS_HPP ${CMAKE_CURRENT_LIST_DIR}/version_macros.hpp) -elseif(EXISTS "${GIT_HEAD_FILE}") - configure_file( - "MakeVersionMacros.cmake.in" - ${CMAKE_CURRENT_BINARY_DIR}/MakeVersionMacros.cmake - @ONLY) - include(${CMAKE_CURRENT_BINARY_DIR}/MakeVersionMacros.cmake) - if(SILKIT_INSTALL_SOURCE) - install(FILES - ${CMAKE_CURRENT_BINARY_DIR}/version_macros.hpp - DESTINATION - ${INSTALL_SOURCE_DIR}/SilKit/source - COMPONENT source - ) - endif() -else() - message(STATUS "SIL Kit: Cannot determine hash of current git head! GIT_HEAD_HASH will be set to UNKNOWN") - set(GIT_HEAD_HASH "UNKNOWN") - configure_file( - version_macros.hpp.in - ${CMAKE_CURRENT_BINARY_DIR}/version_macros.hpp - @ONLY) -endif() -# now install the configured version_macros.hpp as a header into the `include/silkit/capi` directory. -# NB please keep the version_macros.hpp C11 compatible! -install(FILES - ${VERSION_MACROS_HPP} - DESTINATION ${INSTALL_INCLUDE_DIR}/silkit/capi - RENAME SilKitVersionMacros.h - COMPONENT dev -) - set(silkitLibType SHARED) if(SILKIT_BUILD_STATIC) set(silkitLibType STATIC) @@ -69,6 +28,32 @@ target_include_directories(I_SilKit INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}" ) +# The build number and git hash describe a build, not the source tree, so they +# override the fallbacks in the generated SilKitVersionMacros.h. The build number +# also reaches the .rc resource scripts, which put it in FILEVERSION; CMake passes +# a target's compile definitions to the resource compiler. +target_compile_definitions(I_SilKit + INTERFACE SILKIT_BUILD_NUMBER=${SILKIT_BUILD_NUMBER} +) + +# Only the C++ sources read these, and quoted string defines are fragile in the +# resource compiler, so keep them away from the .rc files. +if(SILKIT_BUILD_GIT_HASH) + target_compile_definitions(I_SilKit + INTERFACE $<$:SILKIT_GIT_HASH="${SILKIT_BUILD_GIT_HASH}"> + ) +endif() + +# A pre-release suffix changes the version string too, and CMake is the only +# place that knows how to compose it (VERSION_STRING in SilKitVersion.cmake). +if(SILKIT_VERSION_SUFFIX) + target_compile_definitions(I_SilKit + INTERFACE + $<$:SILKIT_VERSION_SUFFIX="${SILKIT_VERSION_SUFFIX}"> + $<$:SILKIT_VERSION_STRING="${VERSION_STRING}"> + ) +endif() + if (MSVC) target_compile_definitions(I_SilKit INTERFACE _WIN32_WINNT=0x0601 @@ -144,7 +129,6 @@ target_link_libraries(O_SilKit_CreateSilKitRegistryImpl add_library(O_SilKit_VersionImpl OBJECT SilKitVersionImpl.cpp SilKitVersionImpl.hpp - ${VERSION_MACROS_HPP} ) target_link_libraries(O_SilKit_VersionImpl diff --git a/SilKit/source/MakeVersionMacros.cmake.in b/SilKit/source/MakeVersionMacros.cmake.in deleted file mode 100644 index c384dac40..000000000 --- a/SilKit/source/MakeVersionMacros.cmake.in +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -# -# SPDX-License-Identifier: MIT - -set(gitHashFile "${CMAKE_CURRENT_BINARY_DIR}/git_hash_file") -set(gitHeadFile "${CMAKE_CURRENT_BINARY_DIR}/git_head_file") - -configure_file("@GIT_HEAD_FILE@" ${gitHeadFile} COPYONLY) - -file(READ ${gitHeadFile} GIT_HEAD LIMIT 512) -string(REGEX MATCH "^ref: (.*)\n" GIT_HEAD_REF ${GIT_HEAD}) - -set(linkedHeadFile "${GIT_DIR}/${CMAKE_MATCH_1}") -set(linkedRef "${CMAKE_MATCH_1}") -if(GIT_HEAD_REF) - if(NOT EXISTS "${linkedHeadFile}") - # the workspace might have a .git/packed-refs file instead of - # .git/refs/heads/master - message("-- SIL Kit GIT Version: using ${GIT_DIR}/packed-refs") - file(READ ${GIT_DIR}/packed-refs packedRefs LIMIT 4096) - string(REGEX MATCH "([abcdef0-9]+) ${linkedRef}" headHash ${packedRefs}) - if(NOT headHash) - message(FATAL_ERROR "MakeVersionMacros: cannot find linked git ref \"${linkedRef}\"") - endif() - file(WRITE ${gitHashFile} "${CMAKE_MATCH_1}\n") - else() - message("-- SIL Kit GIT Version: using ${linkedHeadFile}") - configure_file( - "${linkedHeadFile}" - ${gitHashFile} - COPYONLY) - endif() -else() - configure_file( - ${gitHeadFile} - ${gitHashFile} - COPYONLY) -endif() - - -file(READ ${gitHashFile} GIT_HEAD_HASH LIMIT 512) -string(STRIP "${GIT_HEAD_HASH}" GIT_HEAD_HASH) -message(STATUS "SIL Kit GIT Version: ${GIT_HEAD_HASH}") -configure_file( - version_macros.hpp.in - ${CMAKE_CURRENT_BINARY_DIR}/version_macros.hpp - @ONLY) diff --git a/SilKit/source/SilKit.rc b/SilKit/source/SilKit.rc index 6dadcc8df..0b57ab705 100644 --- a/SilKit/source/SilKit.rc +++ b/SilKit/source/SilKit.rc @@ -1,5 +1,5 @@ #include -#include "version_macros.hpp" +#include "silkit/capi/SilKitVersionMacros.h" #pragma code_page(65001) // UTF-8 for © symbol #define STRING_HELPER(x) #x diff --git a/SilKit/source/SilKitVersionImpl.cpp b/SilKit/source/SilKitVersionImpl.cpp index 36be9aaf1..22c24a784 100644 --- a/SilKit/source/SilKitVersionImpl.cpp +++ b/SilKit/source/SilKitVersionImpl.cpp @@ -4,7 +4,7 @@ #include "SilKitVersionImpl.hpp" -#include "version_macros.hpp" +#include "silkit/capi/SilKitVersionMacros.h" namespace SilKit { namespace Version { diff --git a/SilKit/source/util/CMakeLists.txt b/SilKit/source/util/CMakeLists.txt index d9fba7454..8c6de17b1 100644 --- a/SilKit/source/util/CMakeLists.txt +++ b/SilKit/source/util/CMakeLists.txt @@ -85,4 +85,34 @@ add_silkit_test_to_executable(SilKitUnitTests ) +# Maintainer tool for version bumps. Standalone on purpose: it must not depend +# on I_SilKit, since it generates a header the library itself is built from. +# +# The version logic is still built (and tested) when the tool itself is disabled, +# so a cross-compiled build keeps the test coverage without producing a +# maintainer binary that cannot run on the host. +if(SILKIT_BUILD_GENERATE_VERSION OR SILKIT_BUILD_TESTS) + add_library(O_SilKit_Util_GenerateVersion OBJECT + GenerateVersion.hpp + GenerateVersion.cpp + ) + + # Root-relative includes as everywhere else in SilKit/source, so consumers + # write #include "util/GenerateVersion.hpp". + target_include_directories(O_SilKit_Util_GenerateVersion + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/.." + ) +endif() + +if(SILKIT_BUILD_GENERATE_VERSION) + add_executable(sil-kit-generate-version sil-kit-generate-version.cpp) + target_link_libraries(sil-kit-generate-version + PRIVATE O_SilKit_Util_GenerateVersion + ) + set_target_properties(sil-kit-generate-version PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$" + ) +endif() + + add_subdirectory(tests) diff --git a/SilKit/source/util/GenerateVersion.cpp b/SilKit/source/util/GenerateVersion.cpp new file mode 100644 index 000000000..70d229d2e --- /dev/null +++ b/SilKit/source/util/GenerateVersion.cpp @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "GenerateVersion.hpp" + +#include +#include +#include + +namespace SilKit { +namespace VersionGen { + +namespace { + +// The literal contents of SilKitVersionMacros.h. Keep this ASCII only: the file +// is consumed by the MSVC resource compiler as well as by C and C++. +constexpr auto kHeaderTemplate = R"(// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +// GENERATED FILE - DO NOT EDIT BY HAND. +// Written by the sil-kit-generate-version tool from the version numbers in +// SilKit/cmake/SilKitVersion.cmake. See docs/development/release.md. + +#pragma once + +#define SILKIT_VERSION_MAJOR @MAJOR@ +#define SILKIT_VERSION_MINOR @MINOR@ +#define SILKIT_VERSION_PATCH @PATCH@ + +// Everything below describes a build rather than the source tree, so CMake +// supplies it and the values here are only fallbacks: +// -DSILKIT_BUILD_NUMBER=N stamps a build number +// -DSILKIT_BUILD_GIT_HASH= the commit actually built; the fallback is +// the commit that was HEAD when this file was +// generated, i.e. the parent of the bump +// -DSILKIT_VERSION_SUFFIX=rc1 marks a pre-release, which also changes +// SILKIT_VERSION_STRING to "@VERSION_STRING@-rc1" +#ifndef SILKIT_BUILD_NUMBER +#define SILKIT_BUILD_NUMBER 0 +#endif + +#ifndef SILKIT_GIT_HASH +#define SILKIT_GIT_HASH "@GIT_HASH@" +#endif + +#ifndef SILKIT_VERSION_SUFFIX +#define SILKIT_VERSION_SUFFIX "" +#endif + +#ifndef SILKIT_VERSION_STRING +#define SILKIT_VERSION_STRING "@VERSION_STRING@" +#endif +)"; + +void ReplaceAll(std::string& s, const std::string& from, const std::string& to) +{ + for (size_t pos = 0; (pos = s.find(from, pos)) != std::string::npos; pos += to.size()) + { + s.replace(pos, from.size(), to); + } +} + +void SubstituteVar(std::string& s, const std::string& name, const std::string& value) +{ + ReplaceAll(s, "@" + name + "@", value); +} + +// set(SILKIT_VERSION_MAJOR 5), tolerating any whitespace CMake would accept. +std::regex CMakeIntSetter(const std::string& variable) +{ + return std::regex{"(set[ \t]*\\([ \t]*" + variable + "[ \t]+)([0-9]+)"}; +} + +int MatchCMakeInt(const std::string& content, const std::regex& re) +{ + std::smatch match; + if (!std::regex_search(content, match, re)) + { + return -1; + } + return std::atoi(match[2].str().c_str()); +} + +// #define SILKIT_VERSION_MAJOR 5 +int DefineInt(const std::string& content, const std::string& macroName) +{ + std::smatch match; + const std::regex re{"#[ \t]*define[ \t]+" + macroName + "[ \t]+(-?[0-9]+)"}; + if (!std::regex_search(content, match, re)) + { + return -1; + } + return std::atoi(match[1].str().c_str()); +} + +// #define SILKIT_VERSION_SUFFIX "rc1" +bool DefineString(const std::string& content, const std::string& macroName, std::string& out) +{ + std::smatch match; + const std::regex re{"#[ \t]*define[ \t]+" + macroName + "[ \t]+\"([^\"]*)\""}; + if (!std::regex_search(content, match, re)) + { + return false; + } + out = match[1].str(); + return true; +} + +// Replaces the text captured by the given group of the first match. Done by +// position rather than with a regex_replace format string, because "$1" glued +// in front of a digit would read as a reference to group 1x. +bool ReplaceCapture(std::string& content, const std::regex& re, int group, const std::string& value) +{ + std::smatch match; + if (!std::regex_search(content, match, re)) + { + return false; + } + content.replace(static_cast(match.position(group)), static_cast(match.length(group)), value); + return true; +} + +} // namespace + +std::string Version::ToString() const +{ + return std::to_string(major) + "." + std::to_string(minor) + "." + std::to_string(patch); +} + +Version ParseVersionFromCMake(const std::string& content) +{ + Version version; + version.major = MatchCMakeInt(content, CMakeIntSetter("SILKIT_VERSION_MAJOR")); + version.minor = MatchCMakeInt(content, CMakeIntSetter("SILKIT_VERSION_MINOR")); + version.patch = MatchCMakeInt(content, CMakeIntSetter("SILKIT_VERSION_PATCH")); + return version; +} + +Version ParseVersionFromHeader(const std::string& content) +{ + Version version; + version.major = DefineInt(content, "SILKIT_VERSION_MAJOR"); + version.minor = DefineInt(content, "SILKIT_VERSION_MINOR"); + version.patch = DefineInt(content, "SILKIT_VERSION_PATCH"); + return version; +} + +std::string ParseGitHashFromHeader(const std::string& content) +{ + std::string gitHash; + DefineString(content, "SILKIT_GIT_HASH", gitHash); + return gitHash; +} + +bool LooksLikeGeneratedHeader(const std::string& content) +{ + std::string gitHash; + return DefineInt(content, "SILKIT_VERSION_MAJOR") >= 0 && DefineString(content, "SILKIT_GIT_HASH", gitHash); +} + +bool PatchCMakeVersion(std::string& content, const Version& version, std::string& error) +{ + struct Setter + { + const char* variable; + int value; + }; + const Setter setters[] = { + {"SILKIT_VERSION_MAJOR", version.major}, + {"SILKIT_VERSION_MINOR", version.minor}, + {"SILKIT_VERSION_PATCH", version.patch}, + }; + + std::string result = content; + for (const auto& setter : setters) + { + if (!ReplaceCapture(result, CMakeIntSetter(setter.variable), 2, std::to_string(setter.value))) + { + error = std::string{"no 'set("} + setter.variable + " )' found"; + return false; + } + } + + content = result; + return true; +} + +std::string RenderHeader(const std::string& gitHash, const Version& version) +{ + std::string result{kHeaderTemplate}; + SubstituteVar(result, "GIT_HASH", gitHash); + SubstituteVar(result, "MAJOR", std::to_string(version.major)); + SubstituteVar(result, "MINOR", std::to_string(version.minor)); + SubstituteVar(result, "PATCH", std::to_string(version.patch)); + SubstituteVar(result, "VERSION_STRING", version.ToString()); + return result; +} + +std::string FinalizeChangelogHeading(const std::string& content, const Version& version, const std::string& date) +{ + // Only the first '# [x.y.z] - ' heading is rewritten; a body that + // happens to quote another one is left alone. + const std::regex heading{"[ \t]*#[ \t]*\\[[^\\]\r\n]*\\][ \t]*-[ \t]*[^\r\n]*"}; + std::smatch match; + if (!std::regex_search(content, match, heading)) + { + return content; + } + + std::string result = content; + result.replace(static_cast(match.position(0)), static_cast(match.length(0)), + "# [" + version.ToString() + "] - " + date); + return result; +} + +std::string RenderChangelogStub(const Version& version) +{ + return "# [" + version.ToString() + "] - UNRELEASED\n\n> This changelog entry is still empty.\n"; +} + +bool InsertChangelogToctreeEntry(std::string& content, const Version& version, std::string& error) +{ + const std::string entry = "versions/" + version.ToString() + ".md"; + if (content.find(entry) != std::string::npos) + { + error = "'" + entry + "' is already listed"; + return false; + } + + const std::string anchor = "versions/latest.md"; + const size_t anchorPos = content.find(anchor); + if (anchorPos == std::string::npos) + { + error = "no '" + anchor + "' toctree entry to insert after"; + return false; + } + + // Reuse the anchor line's indentation and line ending verbatim. + const size_t lineStart = content.rfind('\n', anchorPos); + const std::string indent = content.substr(lineStart + 1, anchorPos - (lineStart + 1)); + + const size_t lineEnd = content.find('\n', anchorPos); + if (lineEnd == std::string::npos) + { + content += "\n" + indent + entry; + return true; + } + const bool crlf = lineEnd > 0 && content[lineEnd - 1] == '\r'; + const std::string eol = crlf ? "\r\n" : "\n"; + + content.insert(lineEnd + 1, indent + entry + eol); + return true; +} + +std::string TodayIsoDate() +{ + const std::time_t now = std::time(nullptr); + std::tm local{}; +#if defined(_WIN32) + localtime_s(&local, &now); +#else + localtime_r(&now, &local); +#endif + // strftime rather than snprintf: GCC cannot prove that tm_mon and tm_mday + // are two digits wide and warns about a possibly truncated %02d. + char buffer[16] = {}; + if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &local) == 0) + { + return ""; + } + return buffer; +} + +bool UsesCrlf(const std::string& content) +{ + return content.find("\r\n") != std::string::npos; +} + +std::string WithLineEndings(const std::string& content, bool crlf) +{ + std::string result; + result.reserve(content.size() + content.size() / 16); + for (size_t i = 0; i < content.size(); ++i) + { + const char character = content[i]; + if (character == '\r' && i + 1 < content.size() && content[i + 1] == '\n') + { + continue; // the '\n' below re-adds the requested ending + } + if (character == '\n' && crlf) + { + result += '\r'; + } + result += character; + } + return result; +} + +} // namespace VersionGen +} // namespace SilKit diff --git a/SilKit/source/util/GenerateVersion.hpp b/SilKit/source/util/GenerateVersion.hpp new file mode 100644 index 000000000..bc6e9030f --- /dev/null +++ b/SilKit/source/util/GenerateVersion.hpp @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +// Pure text transformations behind the sil-kit-generate-version tool. +// +// Everything here operates on in-memory strings so it can be unit tested +// without touching the source tree. All rendered output uses LF line endings +// and stays within ASCII. + +#pragma once + +#include + +namespace SilKit { +namespace VersionGen { + +struct Version +{ + int major{-1}; + int minor{-1}; + int patch{-1}; + + bool IsValid() const + { + return major >= 0 && minor >= 0 && patch >= 0; + } + + // "5.0.8". There is no suffix here: a pre-release suffix describes a build + // and is supplied by CMake, not stored in the source tree. + std::string ToString() const; + + bool operator==(const Version& other) const + { + return major == other.major && minor == other.minor && patch == other.patch; + } + + bool operator!=(const Version& other) const + { + return !(*this == other); + } +}; + +// Parses SILKIT_VERSION_{MAJOR,MINOR,PATCH} out of the contents of +// SilKit/cmake/SilKitVersion.cmake. Returns an invalid Version if a number is +// missing. +Version ParseVersionFromCMake(const std::string& content); + +// Parses the same values out of a generated SilKitVersionMacros.h. +Version ParseVersionFromHeader(const std::string& content); + +// Extracts SILKIT_GIT_HASH from a generated header, or "" if absent. +std::string ParseGitHashFromHeader(const std::string& content); + +// True if the content is recognizably a generated version-macros header. Used +// to refuse overwriting hand-written headers such as silkit/capi/Version.h. +bool LooksLikeGeneratedHeader(const std::string& content); + +// Rewrites the set(SILKIT_VERSION_*) lines in SilKitVersion.cmake, leaving +// every other byte untouched. Returns false and fills 'error' if one of the +// expected lines is missing. +bool PatchCMakeVersion(std::string& content, const Version& version, std::string& error); + +// Renders the full SilKitVersionMacros.h. 'gitHash' is written as the fallback +// value. The build number and pre-release suffix are not parameters at all: +// CMake supplies those at build time. +std::string RenderHeader(const std::string& gitHash, const Version& version); + +// Rewrites the leading '# [x.y.z] - UNRELEASED' heading of a changelog entry to +// the given version and release date. The body is left untouched. If no such +// heading is found the content is returned unchanged. +std::string FinalizeChangelogHeading(const std::string& content, const Version& version, const std::string& date); + +// The placeholder written to latest.md right after a bump. +std::string RenderChangelogStub(const Version& version); + +// Inserts a 'versions/.md' line into the toctree of +// docs/changelog/overview.rst, directly after 'versions/latest.md'. Returns +// false and fills 'error' if the anchor is missing or the entry already exists. +bool InsertChangelogToctreeEntry(std::string& content, const Version& version, std::string& error); + +// Today's date as YYYY-MM-DD in local time, or "" if the clock cannot be +// formatted. +std::string TodayIsoDate(); + +// True if the content uses CRLF line endings. The working tree may be either, +// depending on core.autocrlf, so generated files follow what is already there. +bool UsesCrlf(const std::string& content); + +// Rewrites CRLF/LF line endings to the requested kind. A lone CR is left alone. +std::string WithLineEndings(const std::string& content, bool crlf); + +} // namespace VersionGen +} // namespace SilKit diff --git a/SilKit/source/util/sil-kit-generate-version.cpp b/SilKit/source/util/sil-kit-generate-version.cpp new file mode 100644 index 000000000..01aa3b24f --- /dev/null +++ b/SilKit/source/util/sil-kit-generate-version.cpp @@ -0,0 +1,637 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +// Standalone maintainer tool that performs a SIL Kit version bump: +// +// * rewrites the version numbers in SilKit/cmake/SilKitVersion.cmake +// * regenerates SilKit/include/silkit/capi/SilKitVersionMacros.h +// * archives docs/changelog/versions/latest.md as .md and lists +// it in docs/changelog/overview.rst +// +// Either all of that succeeds or nothing is written. Run without version +// arguments to only refresh the generated header (e.g. after a rebase). +// +// See docs/development/release.md for the full procedure. +// +// Requires C++17 (std::filesystem). No external dependencies. + +#include "GenerateVersion.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using SilKit::VersionGen::Version; + +namespace { + +// --------------------------------------------------------------------------- +// File helpers +// --------------------------------------------------------------------------- + +// Paths are printed through Show(): streaming an fs::path quotes it and escapes +// the backslashes, which is unreadable on Windows. Normalizing also keeps the +// separators consistent in paths assembled from "a/b/c" fragments. +std::string Show(const fs::path& path) +{ + return path.lexically_normal().make_preferred().string(); +} + +std::string ReadFileFull(const fs::path& path) +{ + std::ifstream file{path, std::ios::binary}; + if (!file.is_open()) + { + return ""; + } + std::ostringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); +} + +// Written in binary mode: the line endings are chosen explicitly by the caller +// to match the working tree rather than left to the platform's text mode. +bool WriteFileFull(const fs::path& path, const std::string& content) +{ + std::ofstream file{path, std::ios::binary | std::ios::trunc}; + if (!file.is_open()) + { + std::cerr << "error: cannot write " << Show(path) << "\n"; + return false; + } + file << content; + if (!file.good()) + { + std::cerr << "error: failed while writing " << Show(path) << "\n"; + return false; + } + return true; +} + +std::string TrimStr(const std::string& str) +{ + const size_t start = str.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) + { + return ""; + } + const size_t end = str.find_last_not_of(" \t\r\n"); + return str.substr(start, end - start + 1); +} + +// --------------------------------------------------------------------------- +// Source tree layout +// --------------------------------------------------------------------------- + +const char* const kCMakeVersionFile = "SilKit/cmake/SilKitVersion.cmake"; +const char* const kVersionMacrosHeader = "SilKit/include/silkit/capi/SilKitVersionMacros.h"; +const char* const kChangelogVersionsDir = "docs/changelog/versions"; +const char* const kChangelogOverview = "docs/changelog/overview.rst"; + +struct Layout +{ + fs::path sourceDir; + fs::path cmakeVersionFile; + fs::path versionsDir; + fs::path latestMd; + fs::path overviewRst; +}; + +Layout MakeLayout(const fs::path& sourceDir) +{ + Layout layout; + layout.sourceDir = sourceDir; + layout.cmakeVersionFile = sourceDir / kCMakeVersionFile; + layout.versionsDir = sourceDir / kChangelogVersionsDir; + layout.latestMd = layout.versionsDir / "latest.md"; + layout.overviewRst = sourceDir / kChangelogOverview; + return layout; +} + +// A directory is the source tree root if it holds SilKitVersion.cmake. +fs::path FindSourceDir() +{ + fs::path dir = fs::current_path(); + while (true) + { + if (fs::exists(dir / kCMakeVersionFile)) + { + return dir; + } + const fs::path parent = dir.parent_path(); + if (parent == dir) + { + break; + } + dir = parent; + } + return {}; +} + +// --------------------------------------------------------------------------- +// Git hash resolution (no git binary required) +// --------------------------------------------------------------------------- + +// In a linked worktree '.git' is a file containing 'gitdir: '. +fs::path ResolveGitDir(const fs::path& candidate) +{ + if (fs::is_directory(candidate)) + { + return candidate; + } + if (!fs::is_regular_file(candidate)) + { + return {}; + } + + const std::string content = TrimStr(ReadFileFull(candidate)); + const std::string prefix = "gitdir: "; + if (content.compare(0, prefix.size(), prefix) != 0) + { + return {}; + } + fs::path gitDir = TrimStr(content.substr(prefix.size())); + if (gitDir.is_relative()) + { + gitDir = candidate.parent_path() / gitDir; + } + return fs::is_directory(gitDir) ? gitDir : fs::path{}; +} + +std::string FindPackedRef(const fs::path& packedRefs, const std::string& ref) +{ + const std::string content = ReadFileFull(packedRefs); + if (content.empty()) + { + return ""; + } + std::istringstream stream{content}; + std::string line; + while (std::getline(stream, line)) + { + if (line.empty() || line[0] == '#' || line[0] == '^') + { + continue; + } + const size_t space = line.find(' '); + if (space != std::string::npos && TrimStr(line.substr(space + 1)) == ref) + { + return line.substr(0, space); + } + } + return ""; +} + +std::string ResolveGitHash(const fs::path& gitDir) +{ + const std::string head = TrimStr(ReadFileFull(gitDir / "HEAD")); + if (head.empty()) + { + return "UNKNOWN"; + } + + const std::string refPrefix = "ref: "; + if (head.compare(0, refPrefix.size(), refPrefix) != 0) + { + // Detached HEAD: the content is the hash itself. + return head.size() >= 40 ? head.substr(0, 40) : head; + } + + const std::string ref = TrimStr(head.substr(refPrefix.size())); + + // A linked worktree keeps HEAD locally but shares refs via 'commondir'. + std::vector searchDirs{gitDir}; + const std::string commonDir = TrimStr(ReadFileFull(gitDir / "commondir")); + if (!commonDir.empty()) + { + fs::path common = commonDir; + if (common.is_relative()) + { + common = gitDir / common; + } + searchDirs.push_back(fs::weakly_canonical(common)); + } + + for (const auto& dir : searchDirs) + { + if (fs::exists(dir / ref)) + { + return TrimStr(ReadFileFull(dir / ref)); + } + } + for (const auto& dir : searchDirs) + { + const std::string packed = FindPackedRef(dir / "packed-refs", ref); + if (!packed.empty()) + { + return packed; + } + } + return "UNKNOWN"; +} + +// --------------------------------------------------------------------------- +// Command line +// --------------------------------------------------------------------------- + +void PrintUsage(const char* prog) +{ + std::cout << "Usage: " << prog << " [options] [output-header|-]\n" + << "\n" + << "Performs a SIL Kit version bump: patches SilKitVersion.cmake, regenerates\n" + << "SilKitVersionMacros.h, and rotates the changelog. Without --major/--minor/\n" + << "--patch it only regenerates the header from the current version.\n" + << "\n" + << " --major N New version major (requires --minor and --patch)\n" + << " --minor N New version minor\n" + << " --patch N New version patch\n" + << " --date YYYY-MM-DD Release date for the archived changelog entry\n" + << " (default: today)\n" + << " --source-dir PATH Source tree root (default: search upward from CWD)\n" + << " --git-dir PATH Path to .git (default: /.git)\n" + << " --git-hash HASH Override the git hash; skips reading .git\n" + << " --no-changelog Do not rotate the changelog\n" + << " --force Overwrite an existing archived changelog entry\n" + << " --check Verify the header matches SilKitVersion.cmake and exit;\n" + << " writes nothing, non-zero exit on mismatch\n" + << " --dry-run, -n Print what would happen, write nothing\n" + << " --help, -h Show this help\n" + << "\n" + << "The output header defaults to /" << kVersionMacrosHeader << ".\n" + << "Pass '-' to write the header to stdout instead. An existing file that is not\n" + << "a generated version-macros header is never overwritten.\n" + << "\n" + << "The build number is not set here: it is a property of a build, not of the\n" + << "source tree. Pass -DSILKIT_BUILD_NUMBER=N to CMake; the generated header only\n" + << "provides the fallback of 0.\n" + << "\n" + << "See docs/development/release.md for the release procedure.\n"; +} + +struct Options +{ + int major{-1}; + int minor{-1}; + int patch{-1}; + std::string date; + fs::path sourceDir; + fs::path gitDir; + bool haveGitDir{false}; + std::string gitHashOverride; + std::string outputPath; + bool haveOutputPath{false}; + bool noChangelog{false}; + bool force{false}; + bool check{false}; + bool dryRun{false}; +}; + +bool ParseOptions(int argc, char* argv[], Options& options) +{ + for (int i = 1; i < argc; ++i) + { + const std::string arg = argv[i]; + + auto requireNext = [&]() -> std::string { + if (i + 1 >= argc) + { + std::cerr << "error: " << arg << " requires an argument\n"; + std::exit(1); + } + return argv[++i]; + }; + + if (arg == "--help" || arg == "-h") + { + PrintUsage(argv[0]); + std::exit(0); + } + else if (arg == "--major") + { + options.major = std::atoi(requireNext().c_str()); + } + else if (arg == "--minor") + { + options.minor = std::atoi(requireNext().c_str()); + } + else if (arg == "--patch") + { + options.patch = std::atoi(requireNext().c_str()); + } + else if (arg == "--date") + { + options.date = requireNext(); + } + else if (arg == "--source-dir") + { + options.sourceDir = requireNext(); + } + else if (arg == "--git-dir") + { + options.gitDir = requireNext(); + options.haveGitDir = true; + } + else if (arg == "--git-hash") + { + options.gitHashOverride = requireNext(); + } + else if (arg == "--no-changelog") + { + options.noChangelog = true; + } + else if (arg == "--force") + { + options.force = true; + } + else if (arg == "--check") + { + options.check = true; + } + else if (arg == "--dry-run" || arg == "-n") + { + options.dryRun = true; + } + else if (arg == "-" || (!arg.empty() && arg[0] != '-')) + { + if (options.haveOutputPath) + { + std::cerr << "error: more than one output path given ('" << options.outputPath << "' and '" << arg + << "')\n"; + return false; + } + options.outputPath = arg; + options.haveOutputPath = true; + } + else + { + std::cerr << "error: unknown option '" << arg << "'\n"; + PrintUsage(argv[0]); + return false; + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Planned file writes: everything is validated before anything is written +// --------------------------------------------------------------------------- + +struct PlannedWrite +{ + fs::path path; + std::string content; + std::string what; +}; + +bool CommitWrites(const std::vector& writes, bool useCrlf, bool dryRun) +{ + for (const auto& write : writes) + { + if (dryRun) + { + std::cout << "[dry-run] " << write.what << ": " << Show(write.path) << "\n"; + continue; + } + if (!WriteFileFull(write.path, SilKit::VersionGen::WithLineEndings(write.content, useCrlf))) + { + return false; + } + std::cout << write.what << ": " << Show(write.path) << "\n"; + } + return true; +} + +int RunCheck(const Layout& layout, const fs::path& headerPath, const Version& cmakeVersion) +{ + if (!fs::exists(headerPath)) + { + std::cerr << "error: " << Show(headerPath) << " does not exist\n"; + return 1; + } + + const std::string headerContent = ReadFileFull(headerPath); + const Version headerVersion = SilKit::VersionGen::ParseVersionFromHeader(headerContent); + if (!headerVersion.IsValid()) + { + std::cerr << "error: " << Show(headerPath) << " has no usable version macros\n"; + return 1; + } + if (headerVersion != cmakeVersion) + { + std::cerr << "error: version drift\n" + << " " << Show(layout.cmakeVersionFile) << ": " << cmakeVersion.ToString() << "\n" + << " " << Show(headerPath) << ": " << headerVersion.ToString() << "\n" + << " run sil-kit-generate-version (without version arguments) to regenerate the header\n"; + return 1; + } + + std::cout << "ok: " << Show(headerPath) << " matches " << Show(layout.cmakeVersionFile) << " at " + << cmakeVersion.ToString() << "\n"; + return 0; +} + +} // namespace + +int main(int argc, char* argv[]) +{ + Options options; + if (!ParseOptions(argc, argv, options)) + { + return 1; + } + + // --- locate the source tree ------------------------------------------- + + fs::path sourceDir = options.sourceDir; + if (sourceDir.empty()) + { + sourceDir = FindSourceDir(); + } + if (sourceDir.empty() || !fs::exists(sourceDir / kCMakeVersionFile)) + { + std::cerr << "error: no SIL Kit source tree found (expected " << kCMakeVersionFile << ")\n" + << " run from inside the source tree or pass --source-dir PATH\n"; + return 1; + } + const Layout layout = MakeLayout(sourceDir); + + // --- the version currently recorded in the source tree ------------------ + + std::string cmakeContent = ReadFileFull(layout.cmakeVersionFile); + // Generated files follow the line endings the working tree already uses, + // which depends on git's core.autocrlf. + const bool useCrlf = SilKit::VersionGen::UsesCrlf(cmakeContent); + const Version currentVersion = SilKit::VersionGen::ParseVersionFromCMake(cmakeContent); + if (!currentVersion.IsValid()) + { + std::cerr << "error: cannot read the version from " << Show(layout.cmakeVersionFile) << "\n"; + return 1; + } + + // --- output header ------------------------------------------------------ + + const bool toStdout = options.outputPath == "-"; + const fs::path headerPath = + (options.haveOutputPath && !toStdout) ? fs::path{options.outputPath} : sourceDir / kVersionMacrosHeader; + + if (options.check) + { + return RunCheck(layout, headerPath, currentVersion); + } + + // --- the version being written ------------------------------------------ + + const int given = (options.major >= 0) + (options.minor >= 0) + (options.patch >= 0); + if (given != 0 && given != 3) + { + std::cerr << "error: --major, --minor and --patch must be given together\n"; + return 1; + } + + Version newVersion = currentVersion; + if (given == 3) + { + newVersion.major = options.major; + newVersion.minor = options.minor; + newVersion.patch = options.patch; + } + + const bool bumping = newVersion != currentVersion; + + // --- guard the output header -------------------------------------------- + + if (!toStdout && fs::exists(headerPath)) + { + const std::string existing = ReadFileFull(headerPath); + if (!SilKit::VersionGen::LooksLikeGeneratedHeader(existing)) + { + std::cerr << "error: refusing to overwrite " << Show(headerPath) << "\n" + << " this is not a generated version-macros header (no SILKIT_VERSION_MAJOR /\n" + << " SILKIT_GIT_HASH defines). Did you mean " << kVersionMacrosHeader << "?\n"; + return 1; + } + + // Report drift instead of silently taking the header's word for it. + const Version headerVersion = SilKit::VersionGen::ParseVersionFromHeader(existing); + if (headerVersion.IsValid() && headerVersion != currentVersion) + { + std::cout << "note: header was at " << headerVersion.ToString() << ", " << Show(layout.cmakeVersionFile) + << " says " << currentVersion.ToString() << "; using the latter\n"; + } + } + + // --- git hash ----------------------------------------------------------- + + std::string gitHash = options.gitHashOverride; + if (gitHash.empty()) + { + const fs::path gitDir = ResolveGitDir(options.haveGitDir ? options.gitDir : sourceDir / ".git"); + gitHash = gitDir.empty() ? "UNKNOWN" : ResolveGitHash(gitDir); + if (gitHash == "UNKNOWN") + { + std::cerr << "warning: could not determine the git hash; writing \"UNKNOWN\"\n"; + } + } + + // --- stdout mode: no side effects --------------------------------------- + + if (toStdout) + { + std::cout << SilKit::VersionGen::RenderHeader(gitHash, newVersion); + return 0; + } + + // --- plan every write, validating as we go ------------------------------ + + std::vector writes; + const bool rotating = bumping && !options.noChangelog; + + if (bumping) + { + std::string error; + if (!SilKit::VersionGen::PatchCMakeVersion(cmakeContent, newVersion, error)) + { + std::cerr << "error: cannot update " << Show(layout.cmakeVersionFile) << ": " << error << "\n"; + return 1; + } + writes.push_back({layout.cmakeVersionFile, cmakeContent, + "version " + currentVersion.ToString() + " -> " + newVersion.ToString()}); + } + + if (rotating) + { + const fs::path archiveMd = layout.versionsDir / (currentVersion.ToString() + ".md"); + + if (!fs::exists(layout.latestMd)) + { + std::cerr << "error: " << Show(layout.latestMd) << " not found; cannot rotate the changelog\n" + << " pass --no-changelog to bump the version anyway\n"; + return 1; + } + if (fs::exists(archiveMd) && !options.force) + { + std::cerr << "error: " << Show(archiveMd) << " already exists\n" + << " " << Show(layout.cmakeVersionFile) << " says the current version is " + << currentVersion.ToString() << ", but that entry is already archived.\n" + << " Check the version numbers, or pass --force to overwrite, or --no-changelog\n" + << " to leave the changelog alone.\n"; + return 1; + } + if (!fs::exists(layout.overviewRst)) + { + std::cerr << "error: " << Show(layout.overviewRst) << " not found\n"; + return 1; + } + + const std::string date = options.date.empty() ? SilKit::VersionGen::TodayIsoDate() : options.date; + if (date.empty()) + { + std::cerr << "error: cannot determine today's date; pass --date YYYY-MM-DD\n"; + return 1; + } + const std::string latestContent = ReadFileFull(layout.latestMd); + + writes.push_back({archiveMd, SilKit::VersionGen::FinalizeChangelogHeading(latestContent, currentVersion, date), + "changelog: archive " + currentVersion.ToString() + " (" + date + ")"}); + writes.push_back({layout.latestMd, SilKit::VersionGen::RenderChangelogStub(newVersion), + "changelog: reset latest.md for " + newVersion.ToString()}); + + std::string overviewContent = ReadFileFull(layout.overviewRst); + std::string error; + if (SilKit::VersionGen::InsertChangelogToctreeEntry(overviewContent, currentVersion, error)) + { + writes.push_back({layout.overviewRst, overviewContent, + "changelog: list " + currentVersion.ToString() + ".md in the toctree"}); + } + else if (!options.force) + { + std::cerr << "error: cannot update " << Show(layout.overviewRst) << ": " << error << "\n"; + return 1; + } + else + { + std::cout << "note: " << Show(layout.overviewRst) << " left unchanged: " << error << "\n"; + } + } + + writes.push_back({headerPath, SilKit::VersionGen::RenderHeader(gitHash, newVersion), + "header " + newVersion.ToString() + " (git hash: " + gitHash + ")"}); + + if (!CommitWrites(writes, useCrlf, options.dryRun)) + { + return 1; + } + + if (!options.dryRun && bumping) + { + std::cout << "\nNext: fill in " << Show(layout.latestMd) << " and review 'git diff'.\n" + << "See docs/development/release.md.\n"; + } + return 0; +} diff --git a/SilKit/source/util/tests/CMakeLists.txt b/SilKit/source/util/tests/CMakeLists.txt index a414b774f..c3dded289 100644 --- a/SilKit/source/util/tests/CMakeLists.txt +++ b/SilKit/source/util/tests/CMakeLists.txt @@ -10,3 +10,4 @@ add_silkit_test_to_executable(SilKitUnitTests SOURCES Test_Timer.cpp LIBS I_SilK add_silkit_test_to_executable(SilKitUnitTests SOURCES Test_Util_FileHelpers.cpp LIBS O_SilKit_Util_FileHelpers) add_silkit_test_to_executable(SilKitUnitTests SOURCES Test_Util_StringHelpers.cpp LIBS O_SilKit_Util_StringHelpers) add_silkit_test_to_executable(SilKitUnitTests SOURCES Test_Uri.cpp) +add_silkit_test_to_executable(SilKitUnitTests SOURCES Test_Util_GenerateVersion.cpp LIBS O_SilKit_Util_GenerateVersion) diff --git a/SilKit/source/util/tests/Test_Util_GenerateVersion.cpp b/SilKit/source/util/tests/Test_Util_GenerateVersion.cpp new file mode 100644 index 000000000..b02515fe0 --- /dev/null +++ b/SilKit/source/util/tests/Test_Util_GenerateVersion.cpp @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "gtest/gtest.h" + +#include "util/GenerateVersion.hpp" + +namespace { + +using namespace SilKit::VersionGen; + +// A faithful excerpt of SilKit/cmake/SilKitVersion.cmake, including the +// surrounding lines that must survive a patch untouched. +const char* const kCMakeVersion = R"(# SPDX-License-Identifier: MIT + +macro(configure_silkit_version project_name) + set(SILKIT_VERSION_MAJOR 5) + set(SILKIT_VERSION_MINOR 0) + set(SILKIT_VERSION_PATCH 8) + set(SILKIT_BUILD_NUMBER 0 CACHE STRING "The build number") + set(SILKIT_VERSION_SUFFIX "") + + set(VERSION_STRING "${SILKIT_VERSION_MAJOR}.${SILKIT_VERSION_MINOR}.${SILKIT_VERSION_PATCH}") +endmacro() +)"; + +// The header as committed before the generated-file banner and the #ifndef +// build-number fallback were introduced. Kept verbatim: LooksLikeGeneratedHeader +// must still accept an older generated header. +const char* const kLegacyHeader = R"(#pragma once + +#define SILKIT_GIT_HASH "23932429ac68eecdb8ca7698f35783ee5d89a04b" +#define SILKIT_VERSION_MAJOR 5 +#define SILKIT_VERSION_MINOR 0 +#define SILKIT_VERSION_PATCH 6 +#define SILKIT_BUILD_NUMBER 42 +#define SILKIT_VERSION_STRING "5.0.6" +#define SILKIT_VERSION_SUFFIX "" +)"; + +// silkit/capi/Version.h, the hand-written public API header that must never be +// mistaken for a generated one. +const char* const kPublicApiHeader = R"(#pragma once +#include "silkit/capi/SilKitMacros.h" + +SILKIT_BEGIN_DECLS +SilKitAPI SilKit_ReturnCode SilKitCALL SilKit_Version_Major(uint32_t* outVersionMajor); +SILKIT_END_DECLS +)"; + +const char* const kOverviewRst = R"(Changelog +========= + +.. toctree:: + :maxdepth: 1 + :glob: + + versions/latest.md + versions/5.0.7.md + versions/4.rst +)"; + +TEST(Test_Util_GenerateVersion, ParseVersionFromCmake) +{ + const auto version = ParseVersionFromCMake(kCMakeVersion); + ASSERT_TRUE(version.IsValid()); + EXPECT_EQ(version.major, 5); + EXPECT_EQ(version.minor, 0); + EXPECT_EQ(version.patch, 8); + EXPECT_EQ(version.ToString(), "5.0.8"); +} + +TEST(Test_Util_GenerateVersion, ParseVersionFromCmakeIgnoresBuildNumber) +{ + // SILKIT_BUILD_NUMBER carries a CACHE clause and must not be mistaken for a + // version component. + const auto version = ParseVersionFromCMake(kCMakeVersion); + EXPECT_EQ(version.ToString(), "5.0.8"); +} + +TEST(Test_Util_GenerateVersion, ParseVersionFromCmakeWithoutVersionIsInvalid) +{ + EXPECT_FALSE(ParseVersionFromCMake("# nothing here\n").IsValid()); +} + +TEST(Test_Util_GenerateVersion, ParseVersionFromHeader) +{ + const auto version = ParseVersionFromHeader(kLegacyHeader); + ASSERT_TRUE(version.IsValid()); + EXPECT_EQ(version.ToString(), "5.0.6"); + EXPECT_EQ(ParseGitHashFromHeader(kLegacyHeader), "23932429ac68eecdb8ca7698f35783ee5d89a04b"); +} + +TEST(Test_Util_GenerateVersion, GeneratedHeaderIsRecognized) +{ + EXPECT_TRUE(LooksLikeGeneratedHeader(kLegacyHeader)); + + Version version; + version.major = 5; + version.minor = 0; + version.patch = 9; + EXPECT_TRUE(LooksLikeGeneratedHeader(RenderHeader("abc", version))); +} + +TEST(Test_Util_GenerateVersion, PublicApiHeaderIsNotMistakenForAGeneratedOne) +{ + // This is the guard that keeps a mistyped output path from clobbering + // silkit/capi/Version.h. + EXPECT_FALSE(LooksLikeGeneratedHeader(kPublicApiHeader)); + EXPECT_FALSE(LooksLikeGeneratedHeader("")); +} + +TEST(Test_Util_GenerateVersion, PatchCmakeVersionOnlyTouchesTheVersionLines) +{ + Version version; + version.major = 6; + version.minor = 1; + version.patch = 2; + + std::string content{kCMakeVersion}; + std::string error; + ASSERT_TRUE(PatchCMakeVersion(content, version, error)) << error; + + EXPECT_NE(content.find("set(SILKIT_VERSION_MAJOR 6)"), std::string::npos); + EXPECT_NE(content.find("set(SILKIT_VERSION_MINOR 1)"), std::string::npos); + EXPECT_NE(content.find("set(SILKIT_VERSION_PATCH 2)"), std::string::npos); + + // Everything else survives verbatim, the suffix included: it is a build + // input now, not something a version bump rewrites. + EXPECT_NE(content.find("set(SILKIT_VERSION_SUFFIX \"\")"), std::string::npos); + EXPECT_NE(content.find("set(SILKIT_BUILD_NUMBER 0 CACHE STRING \"The build number\")"), std::string::npos); + EXPECT_NE(content.find("macro(configure_silkit_version project_name)"), std::string::npos); + EXPECT_NE(content.find("${SILKIT_VERSION_MAJOR}.${SILKIT_VERSION_MINOR}"), std::string::npos); + + EXPECT_EQ(ParseVersionFromCMake(content).ToString(), "6.1.2"); +} + +TEST(Test_Util_GenerateVersion, PatchCmakeVersionReportsAMissingSetter) +{ + Version version; + version.major = 1; + version.minor = 2; + version.patch = 3; + + std::string content = "set(SILKIT_VERSION_MAJOR 5)\n"; + std::string error; + EXPECT_FALSE(PatchCMakeVersion(content, version, error)); + EXPECT_FALSE(error.empty()); +} + +TEST(Test_Util_GenerateVersion, RenderedHeaderIsAsciiLfAndRoundTrips) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 9; + + const auto header = RenderHeader("deadbeef", version); + + for (const char character : header) + { + EXPECT_GE(static_cast(character), 0x09u) << "non-ASCII byte in the generated header"; + EXPECT_LT(static_cast(character), 0x80u) << "non-ASCII byte in the generated header"; + } + EXPECT_EQ(header.find('\r'), std::string::npos) << "the generated header must use LF line endings"; + + EXPECT_NE(header.find("#define SILKIT_VERSION_STRING \"5.0.9\""), std::string::npos); + EXPECT_NE(header.find("DO NOT EDIT"), std::string::npos); + + EXPECT_EQ(ParseVersionFromHeader(header).ToString(), "5.0.9"); + EXPECT_EQ(ParseGitHashFromHeader(header), "deadbeef"); +} + +// The build number, git hash and pre-release suffix are properties of a build, +// supplied by CMake. The header must guard each of them, or the definitions +// could never take effect. +void ExpectGuardedFallback(const std::string& header, const std::string& macroName) +{ + const auto guard = header.find("#ifndef " + macroName); + const auto define = header.find("#define " + macroName); + EXPECT_NE(guard, std::string::npos) << macroName << " is not guarded by #ifndef"; + EXPECT_LT(guard, define) << macroName << " is defined before its guard"; + + // Exactly one define, so nothing pins the value unconditionally. + EXPECT_EQ(header.find("#define " + macroName, define + 1), std::string::npos) + << macroName << " is defined more than once"; +} + +TEST(Test_Util_GenerateVersion, BuildInputsAreOnlyFallbacksInTheHeader) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + + const auto header = RenderHeader("abc", version); + + ExpectGuardedFallback(header, "SILKIT_BUILD_NUMBER"); + ExpectGuardedFallback(header, "SILKIT_GIT_HASH"); + ExpectGuardedFallback(header, "SILKIT_VERSION_SUFFIX"); + // Guarded too, because a pre-release suffix changes it to "5.0.8-rc1". + ExpectGuardedFallback(header, "SILKIT_VERSION_STRING"); + + EXPECT_NE(header.find("#define SILKIT_BUILD_NUMBER 0"), std::string::npos); + EXPECT_NE(header.find("#define SILKIT_VERSION_SUFFIX \"\""), std::string::npos); + EXPECT_EQ(ParseGitHashFromHeader(header), "abc"); + + // The version numbers are not guarded: they are source tree state. + EXPECT_EQ(header.find("#ifndef SILKIT_VERSION_MAJOR"), std::string::npos); + EXPECT_EQ(header.find("#ifndef SILKIT_VERSION_PATCH"), std::string::npos); +} + +TEST(Test_Util_GenerateVersion, RenderedHeaderIsStable) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + EXPECT_EQ(RenderHeader("abc", version), RenderHeader("abc", version)); +} + +TEST(Test_Util_GenerateVersion, FinalizeChangelogHeadingSetsVersionAndDate) +{ + const std::string entry = "# [5.0.8] - UNRELEASED\n\n## Added\n\n- Something\n"; + + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + + const auto finalized = FinalizeChangelogHeading(entry, version, "2026-09-01"); + EXPECT_EQ(finalized, "# [5.0.8] - 2026-09-01\n\n## Added\n\n- Something\n"); +} + +TEST(Test_Util_GenerateVersion, FinalizeChangelogHeadingCorrectsAStaleVersion) +{ + // After a rebase latest.md may carry the wrong number; the archived file + // must name the version actually being released. + const std::string entry = "# [5.0.6] - UNRELEASED\n\n## Fixed\n"; + + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + + EXPECT_EQ(FinalizeChangelogHeading(entry, version, "2026-09-01"), "# [5.0.8] - 2026-09-01\n\n## Fixed\n"); +} + +TEST(Test_Util_GenerateVersion, FinalizeChangelogHeadingLeavesAHeadinglessEntryAlone) +{ + const std::string entry = "no heading here\n"; + Version version; + version.major = 1; + version.minor = 0; + version.patch = 0; + EXPECT_EQ(FinalizeChangelogHeading(entry, version, "2026-09-01"), entry); +} + +TEST(Test_Util_GenerateVersion, ChangelogStubMatchesTheEstablishedFormat) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 9; + EXPECT_EQ(RenderChangelogStub(version), "# [5.0.9] - UNRELEASED\n\n> This changelog entry is still empty.\n"); +} + +TEST(Test_Util_GenerateVersion, InsertToctreeEntryAfterLatest) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + + std::string content{kOverviewRst}; + std::string error; + ASSERT_TRUE(InsertChangelogToctreeEntry(content, version, error)) << error; + + const size_t latest = content.find("versions/latest.md"); + const size_t inserted = content.find("versions/5.0.8.md"); + const size_t next = content.find("versions/5.0.7.md"); + ASSERT_NE(inserted, std::string::npos); + EXPECT_LT(latest, inserted); + EXPECT_LT(inserted, next); + + // The toctree indentation is preserved. + EXPECT_NE(content.find("\n versions/5.0.8.md\n"), std::string::npos); +} + +TEST(Test_Util_GenerateVersion, InsertToctreeEntryRejectsADuplicate) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 7; + + std::string content{kOverviewRst}; + std::string error; + EXPECT_FALSE(InsertChangelogToctreeEntry(content, version, error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(content, kOverviewRst); +} + +TEST(Test_Util_GenerateVersion, InsertToctreeEntryReportsAMissingAnchor) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + + std::string content = "Changelog\n=========\n"; + std::string error; + EXPECT_FALSE(InsertChangelogToctreeEntry(content, version, error)); + EXPECT_FALSE(error.empty()); +} + +TEST(Test_Util_GenerateVersion, InsertToctreeEntryKeepsCrlfLineEndings) +{ + Version version; + version.major = 5; + version.minor = 0; + version.patch = 8; + + std::string content = ".. toctree::\r\n\r\n versions/latest.md\r\n versions/4.rst\r\n"; + std::string error; + ASSERT_TRUE(InsertChangelogToctreeEntry(content, version, error)) << error; + EXPECT_NE(content.find("\r\n versions/5.0.8.md\r\n"), std::string::npos); +} + +TEST(Test_Util_GenerateVersion, DetectLineEndings) +{ + EXPECT_TRUE(UsesCrlf("a\r\nb\r\n")); + EXPECT_FALSE(UsesCrlf("a\nb\n")); + EXPECT_FALSE(UsesCrlf("")); +} + +TEST(Test_Util_GenerateVersion, ApplyLineEndings) +{ + EXPECT_EQ(WithLineEndings("a\nb\n", true), "a\r\nb\r\n"); + EXPECT_EQ(WithLineEndings("a\r\nb\r\n", false), "a\nb\n"); + + // Idempotent, so applying the working tree's endings to content spliced out + // of an existing file cannot double them up. + EXPECT_EQ(WithLineEndings("a\r\nb\r\n", true), "a\r\nb\r\n"); + EXPECT_EQ(WithLineEndings("a\nb\n", false), "a\nb\n"); + + // A lone CR is not a line ending and is left alone. + EXPECT_EQ(WithLineEndings("a\rb\n", true), "a\rb\r\n"); +} + +TEST(Test_Util_GenerateVersion, TodayIsAnIsoDate) +{ + const auto today = TodayIsoDate(); + ASSERT_EQ(today.size(), 10u); + EXPECT_EQ(today[4], '-'); + EXPECT_EQ(today[7], '-'); +} + +} // anonymous namespace diff --git a/SilKit/source/version_macros.hpp.in b/SilKit/source/version_macros.hpp.in deleted file mode 100644 index 6529e06be..000000000 --- a/SilKit/source/version_macros.hpp.in +++ /dev/null @@ -1,14 +0,0 @@ - -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#define SILKIT_GIT_HASH "@GIT_HEAD_HASH@" -#define SILKIT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@ -#define SILKIT_VERSION_MINOR @PROJECT_VERSION_MINOR@ -#define SILKIT_VERSION_PATCH @PROJECT_VERSION_PATCH@ -#define SILKIT_BUILD_NUMBER @SILKIT_BUILD_NUMBER@ -#define SILKIT_VERSION_STRING "@PROJECT_VERSION@" -#define SILKIT_VERSION_SUFFIX "@SILKIT_VERSION_SUFFIX@" diff --git a/Utilities/SilKitMonitor/CMakeLists.txt b/Utilities/SilKitMonitor/CMakeLists.txt index 3639bfa9e..c4ea08297 100644 --- a/Utilities/SilKitMonitor/CMakeLists.txt +++ b/Utilities/SilKitMonitor/CMakeLists.txt @@ -29,12 +29,10 @@ target_link_libraries(sil-kit-monitor # Set versioning infos on exe if(MSVC) - get_target_property(SILKIT_BINARY_DIR SilKit BINARY_DIR) get_target_property(SILKIT_SOURCE_DIR SilKit SOURCE_DIR) - # Include the generated version_macros.hpp in SilKit/source + # Include SilKit/include so the RC compiler finds silkit/capi/SilKitVersionMacros.h target_include_directories(sil-kit-monitor - PRIVATE ${SILKIT_BINARY_DIR} - PRIVATE ${SILKIT_SOURCE_DIR} + PRIVATE ${SILKIT_SOURCE_DIR}/../include ) target_sources(sil-kit-monitor PRIVATE sil-kit-monitor.rc) endif() diff --git a/Utilities/SilKitMonitor/sil-kit-monitor.rc b/Utilities/SilKitMonitor/sil-kit-monitor.rc index 0c9d3e583..26dafb501 100644 --- a/Utilities/SilKitMonitor/sil-kit-monitor.rc +++ b/Utilities/SilKitMonitor/sil-kit-monitor.rc @@ -1,5 +1,5 @@ #include -#include "version_macros.hpp" +#include "silkit/capi/SilKitVersionMacros.h" #pragma code_page(65001) // UTF-8 for © symbol #define STRING_HELPER(x) #x diff --git a/Utilities/SilKitRegistry/CMakeLists.txt b/Utilities/SilKitRegistry/CMakeLists.txt index bb0f8f56b..9194b001b 100644 --- a/Utilities/SilKitRegistry/CMakeLists.txt +++ b/Utilities/SilKitRegistry/CMakeLists.txt @@ -46,12 +46,10 @@ target_link_libraries(sil-kit-registry # Set versioning infos on exe if(MSVC) - get_target_property(SILKIT_BINARY_DIR SilKit BINARY_DIR) get_target_property(SILKIT_SOURCE_DIR SilKit SOURCE_DIR) - # Include the generated version_macros.hpp in SilKit/source + # Include SilKit/include so the RC compiler finds silkit/capi/SilKitVersionMacros.h target_include_directories(sil-kit-registry - PRIVATE ${SILKIT_BINARY_DIR} - PRIVATE ${SILKIT_SOURCE_DIR} + PRIVATE ${SILKIT_SOURCE_DIR}/../include ) target_sources(sil-kit-registry PRIVATE SilKitRegistry.rc) endif() diff --git a/Utilities/SilKitRegistry/SilKitRegistry.rc b/Utilities/SilKitRegistry/SilKitRegistry.rc index d4e1c0226..b4e810376 100644 --- a/Utilities/SilKitRegistry/SilKitRegistry.rc +++ b/Utilities/SilKitRegistry/SilKitRegistry.rc @@ -1,5 +1,5 @@ #include -#include "version_macros.hpp" +#include "silkit/capi/SilKitVersionMacros.h" #pragma code_page(65001) // UTF-8 for © symbol #define STRING_HELPER(x) #x diff --git a/Utilities/SilKitSystemController/CMakeLists.txt b/Utilities/SilKitSystemController/CMakeLists.txt index 46d5968f3..d08a061b4 100644 --- a/Utilities/SilKitSystemController/CMakeLists.txt +++ b/Utilities/SilKitSystemController/CMakeLists.txt @@ -29,12 +29,10 @@ target_link_libraries(sil-kit-system-controller # Set versioning infos on exe if(MSVC) - get_target_property(SILKIT_BINARY_DIR SilKit BINARY_DIR) get_target_property(SILKIT_SOURCE_DIR SilKit SOURCE_DIR) - # Include the generated version_macros.hpp in SilKit/source + # Include SilKit/include so the RC compiler finds silkit/capi/SilKitVersionMacros.h target_include_directories(sil-kit-system-controller - PRIVATE ${SILKIT_BINARY_DIR} - PRIVATE ${SILKIT_SOURCE_DIR} + PRIVATE ${SILKIT_SOURCE_DIR}/../include ) target_sources(sil-kit-system-controller PRIVATE SilKitSystemController.rc) endif() diff --git a/Utilities/SilKitSystemController/SilKitSystemController.rc b/Utilities/SilKitSystemController/SilKitSystemController.rc index b34c5f3b1..dc29ac43e 100644 --- a/Utilities/SilKitSystemController/SilKitSystemController.rc +++ b/Utilities/SilKitSystemController/SilKitSystemController.rc @@ -1,5 +1,5 @@ #include -#include "version_macros.hpp" +#include "silkit/capi/SilKitVersionMacros.h" #pragma code_page(65001) // UTF-8 for © symbol #define STRING_HELPER(x) #x diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index 0de904ac2..07b551b52 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -42,6 +42,10 @@ set(SPHINX_INDEX_FILE ${SPHINX_BUILD}/index.html) # - Our doc files have been updated # - The Sphinx config has been updated file(GLOB_RECURSE SPHINX_SOURCE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.rst) +# Markdown pages (the changelog entries, development/release.md) are part of the +# Sphinx sources too and must retrigger the build when edited. +file(GLOB_RECURSE SPHINX_MD_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.md) +list(APPEND SPHINX_SOURCE_FILES ${SPHINX_MD_FILES}) file(GLOB_RECURSE SVG_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.svg) list(APPEND SPHINX_SOURCE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/_static/custom.css) diff --git a/docs/changelog/versions/latest.md b/docs/changelog/versions/latest.md index 2e63e8d26..a6b088f5b 100644 --- a/docs/changelog/versions/latest.md +++ b/docs/changelog/versions/latest.md @@ -4,6 +4,9 @@ ## Added - Add Integration Test for Timestamp Behavior +- New CMake option `SILKIT_BUILD_GENERATE_VERSION` (default `ON`) to build the `sil-kit-generate-version` maintainer + tool. Turn it off when cross-compiling: the tool runs on the maintainer's machine, so building it for the target + architecture produces an unrunnable binary. ## Fixed @@ -13,6 +16,14 @@ ## Changed +- `SilKitVersionMacros.h` is now committed to the source tree instead of being generated at CMake configure time. + The new `sil-kit-generate-version` maintainer tool regenerates it and performs a complete version bump + (`SilKitVersion.cmake`, the generated header and the changelog) in one step. See `docs/development/release.md`. +- The build number, git hash and pre-release suffix are now build-time settings (`cmake -DSILKIT_BUILD_NUMBER=N`, + `-DSILKIT_BUILD_GIT_HASH=`, `-DSILKIT_VERSION_SUFFIX=rc1`) rather than values stored in the source tree. + The generated header carries only `#ifndef` fallbacks, so a build that passes its own hash makes + `SilKit::Version::GitHash()` report the commit actually built, and a build that sets a suffix reports + `5.0.8-rc1` from `SilKit::Version::String()` and in the CPack archive name. - Changes to the SIL KIT MSI installer: - Default installation path changed from `\Vector SIL Kit ` to `\SIL Kit ` - Windows System Service Name changed from `VectorSilKitRegistry` to `SilKitRegistry` diff --git a/docs/development/build.rst b/docs/development/build.rst index b44aadb59..a08fd6057 100644 --- a/docs/development/build.rst +++ b/docs/development/build.rst @@ -38,6 +38,10 @@ The following options are available: - Build the demo applications * - SILKIT_BUILD_DOCS - Build the documentation using Doxygen and Sphinx + * - SILKIT_BUILD_GENERATE_VERSION + - Build the ``sil-kit-generate-version`` maintainer tool (see :doc:`release`). + It is not installed or packaged; turn it off when cross-compiling, since the + resulting binary would not run on the host. * - SILKIT_INSTALL_SOURCE - Installs the source-tree (used for packaging releases). Implies SILKIT_BUILD_DOCS. @@ -69,9 +73,12 @@ Refer to :doc:`rst-help` for guidelines on formatting the documentation. ~~~~~~~~~~~~~ SIL Kit uses CPack to generate the release distributions in ZIP form. It can be packaged using the *package* target:: - + cmake --build . --target package +Refer to :doc:`release` for bumping the version number and rotating the +changelog before a release. + The generated package adheres to the following template ``SilKit----.zip``. Its contents are as follows: diff --git a/docs/development/release.md b/docs/development/release.md new file mode 100644 index 000000000..c9cd27cc7 --- /dev/null +++ b/docs/development/release.md @@ -0,0 +1,117 @@ +--- +orphan: true +--- + +# !!! Version Bumps and the Changelog + +For maintainers. One tool does all of it: `sil-kit-generate-version`. Do not edit +the version by hand, several files have to agree. + +## Where the version lives + +| File | Who writes it | +| --- | --- | +| `SilKit/cmake/SilKitVersion.cmake` | **Source of truth.** Patched by the tool. | +| `SilKit/include/silkit/capi/SilKitVersionMacros.h` | **Generated. Never edit by hand.** Committed, and compiled into the library and the utilities' Windows resources. | +| `docs/changelog/versions/latest.md` | Hand-written as changes land; reset by the tool on a bump. | +| `docs/changelog/versions/.md` | Written by the tool when it archives `latest.md`. | +| `docs/changelog/overview.rst` | Toctree line for the archived entry, added by the tool. | + +```{warning} +`silkit/capi/Version.h` and `silkit/SilKitVersion.hpp` are hand-written public API +declaring the version *query functions*. They hold no version numbers and a bump +never touches them. Only `SilKitVersionMacros.h` is generated, and the tool +refuses to write to anything that is not already a generated version header. +``` + +## Build the tool + +``` +cmake --build --target sil-kit-generate-version +``` + +It lands in `/` (multi-config generators: `//`). It +is standalone C++17 with no SIL Kit dependencies, because it generates a header +the library is built from, and it reads `.git` directly rather than invoking +`git`. + +Built by default, never installed. `-DSILKIT_BUILD_GENERATE_VERSION=OFF` skips it +when cross-compiling, where a host tool built for the target is useless. The +version logic keeps its unit tests either way. + +## Bump the version + +Run from anywhere in the source tree. Preview first: + +``` +sil-kit-generate-version --dry-run --major 5 --minor 0 --patch 9 +sil-kit-generate-version --major 5 --minor 0 --patch 9 +``` + +That one command sets the version in `SilKitVersion.cmake`, archives +`latest.md` as `5.0.8.md` with today's date, lists it in `overview.rst`, resets +`latest.md` to an empty `# [5.0.9] - UNRELEASED` stub, and regenerates the +header. Every precondition is checked before the first byte is written, so it +either all happens or none of it does. + +Review with `git diff`; exactly five files change and the `SilKitVersion.cmake` +diff is at most three lines. Anything larger means something went wrong. + +`--date YYYY-MM-DD` overrides the release date. Commit as +`version: bump to X.Y.Z (#PR)`. A pre-release is not a bump; see +"Build identity" below. + +## Refresh the header without bumping + +After a rebase or merge the committed hash is stale, and the version may have +drifted from `SilKitVersion.cmake` too. With no version arguments the tool takes +the version from `SilKitVersion.cmake`, refreshes the hash, and rotates nothing: + +``` +sil-kit-generate-version +sil-kit-generate-version --check # non-zero on drift, writes nothing; for CI +``` + +## Build identity + +Only the version numbers are source tree state. The git hash, build number and +pre-release suffix describe a *build*, so the header carries only `#ifndef` +fallbacks and CMake supplies the real values: + +``` +cmake -B -DSILKIT_BUILD_GIT_HASH=$(git rev-parse HEAD) \ + -DSILKIT_BUILD_NUMBER=42 -DSILKIT_VERSION_SUFFIX=rc1 +``` + +- `SILKIT_BUILD_GIT_HASH` makes `SilKit::Version::GitHash()` report the commit + actually built. Unset, it reports the header's fallback: the commit that was + HEAD when the tool last ran, i.e. the parent of the bump. +- `SILKIT_BUILD_NUMBER` defaults to `0` and also fills the Windows `FILEVERSION`. +- `SILKIT_VERSION_SUFFIX` marks a pre-release. It changes + `SilKit::Version::String()` to `5.0.8-rc1` and flows into `PROJECT_VERSION`, so + CPack archive names carry it too. + +None of them is derived per build, which keeps `SILKIT_BUILD_REPRODUCIBLE` (`ON` +by default) meaningful: the same sources and the same flags give the same binary. + +## Troubleshooting + +- **`refusing to overwrite `** - not a generated version header, so you + passed the wrong output path. Drop the path argument; the default is right. +- **`.md already exists`** - `SilKitVersion.cmake` names a version whose + entry is already archived, usually a mid-rebase tree. Check the version numbers + first. `--force` overwrites the archived entry, `--no-changelog` skips + rotation. Nothing has been written yet. +- **`version drift`** (from `--check`) - header and `SilKitVersion.cmake` + disagree. Re-run with no version arguments. +- **`no SIL Kit source tree found`** - run inside the tree or pass + `--source-dir PATH`. +- **`could not determine the git hash`** - `.git` was unreadable, so the fallback + is written as `UNKNOWN`. Pass `--git-dir PATH` or `--git-hash HASH`. + +`sil-kit-generate-version --help` lists every option. + +## See also + +- {doc}`build` for build configuration and packaging +- `docs/for-developers/versioning.md` for what the version numbers promise