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.