This is a bit tricky because multiple ways of doing it are documented. This
is the way that eventually worked for me.

The top-level SConstruct is as normal for an out-of-source build, it reads

SConscript('src/SConscript', variant_dir='build')

You need a header so that your program can recognize the version number. In
C++ this is as follows, in src/version.hh:

extern char const* const version_string;

You can define the version that you want to update in a file named version
which is in the root of the repository. It should have no other content other
than the version number, perhaps along with a newline.

0.0.1

Now the src/SConscript file should look like this:

env = Environment()
# The version file is located in the file called 'version' in the very root
# of the repository.
VERSION_FILE_PATH = '#version'
# Note: You absolutely need to have the #include below, or you're going to get
# an 'undefined reference' message due to the use of const. (it's the second
# const in the type declaration that causes this.)
#
# Both the user of the version and this template itself need to include the
# extern declaration first.
def version_action(target, source, env):
source_path = source[0].path
target_path = target[0].path
# read version from plaintext file
with open(source_path, 'r') as f:
version = f.read().rstrip()
version_c_text = """
#include "version.hh"
const char* const version_string = "%s";
""" % version
with open(target_path, 'w') as f:
f.write(version_c_text)
return 0
env.Command(
target='version.cc',
source=VERSION_FILE_PATH,
action=version_action
)
main_binary = env.Program(
'main', source=['main.cc', 'version.cc']
)

The basic strategy here is to designate the version file as the source file
for version.cc, but we just hardcode the template for the actual C++
definition inside the SConscript itself. Note that the include within the
template is crucial, due to an 'aspect' of the C++ compilation
process.