diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 95a5cc0..005db0c 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -8,6 +8,7 @@ on: env: SPECS_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }} + SPECS_BUILD_SOURCE: github jobs: build-linux: @@ -39,7 +40,7 @@ jobs: run: make all - name: Test specs executable - run: specs/exe/specs "@version" WRITE "@platform" + run: specs/exe/specs "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: make check working-directory: specs/src @@ -67,7 +68,7 @@ jobs: run: make all - name: Test specs executable - run: specs/exe/specs "@version" WRITE "@platform" + run: specs/exe/specs "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: make check working-directory: specs/src @@ -91,7 +92,7 @@ jobs: run: msbuild specs/specs.sln /p:Configuration=Release /p:Platform=x64 - name: Test specs executable - run: specs/bin/Release/specs.exe "@version" WRITE "@platform" + run: specs/bin/Release/specs.exe "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" build-windows-python: runs-on: windows-latest @@ -113,4 +114,4 @@ jobs: run: msbuild specs/specs.sln /p:Configuration=Release /p:Platform=x64 /p:EnablePython=true - name: Test specs executable - run: specs/bin/Release/specs.exe "@version" WRITE "@platform" + run: specs/bin/Release/specs.exe "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a161edc..f8697d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,8 @@ on: env: SPECS_VERSION: ${{ github.event.release.tag_name }} SPECS_BRANCH: ${{ github.event.release.target_commitish }} + SPECS_BUILD_SOURCE: github + SPECS_BUILD_NUMBER: ${{ github.run_number }} jobs: build-linux: @@ -19,10 +21,18 @@ jobs: container: image: ubuntu:22.04 steps: + - name: Install git (so checkout creates a real repository in the container) + run: | + DEBIAN_FRONTEND=noninteractive apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git + - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Mark workspace as safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Dump event release payload env: EVENT: ${{ toJSON(github.event.release) }} @@ -46,7 +56,7 @@ jobs: run: make some - name: Verify binary - run: specs/exe/specs "@version" WRITE "@platform" + run: specs/exe/specs "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: Prepare manpage run: | @@ -121,7 +131,7 @@ jobs: run: make some - name: Verify binary - run: specs/exe/specs "@version" WRITE "@platform" + run: specs/exe/specs "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: Prepare manpage run: | @@ -205,7 +215,7 @@ jobs: run: msbuild specs/specs.sln /p:Configuration=Release /p:Platform=x64 /p:GitTag=${{ steps.version.outputs.display }} - name: Verify binary - run: specs\bin\Release\specs.exe "@version" WRITE "@platform" + run: specs\bin\Release\specs.exe "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: Prepare standalone executable shell: bash @@ -291,7 +301,7 @@ jobs: run: msbuild specs/specs.sln /p:Configuration=Release /p:Platform=x64 /p:GitTag=${{ steps.version.outputs.display }} /p:EnablePython=true - name: Verify binary - run: specs\bin\Release\specs.exe "@version" WRITE "@platform" + run: specs\bin\Release\specs.exe "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: Prepare standalone executable shell: bash @@ -337,10 +347,23 @@ jobs: runs-on: ${{ matrix.runner }} container: ${{ matrix.container || '' }} steps: + - name: Install git (so checkout creates a real repository in the container) + run: | + if [ "$(id -u)" -eq 0 ]; then + APT="apt-get" + else + APT="sudo apt-get" + fi + DEBIAN_FRONTEND=noninteractive $APT update + DEBIAN_FRONTEND=noninteractive $APT install -y git + - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Mark workspace as safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Install build tools and Python 3.12 run: | if [ "$(id -u)" -eq 0 ]; then @@ -366,7 +389,7 @@ jobs: run: make some - name: Verify binary - run: specs/exe/specs "@version" WRITE "@platform" + run: specs/exe/specs "Version:" 1 "@version" WRITE "Platform:" 1 "@platform" WRITE "Build info:" 1 "@build-info" - name: Prepare manpage run: | diff --git a/.gitignore b/.gitignore index fa0913d..3ae0f58 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ specs/src/Release/ # GDB-related specs/src/.gdbinit specs/src/gdb/__pycache__ + +# Generated build info +specs/src/utils/build_info.h diff --git a/manpage b/manpage index 3d97907..3807d70 100644 --- a/manpage +++ b/manpage @@ -2272,11 +2272,11 @@ The system also defines some labels: .IP "version" 3 This returns the version of .B specs, -for example "v0.6" +for example "1.0.0" .P To find out the version of specs that you are using, use the following command: .P - specs @version 1 + specs @version .IP "cols" 3 contains the number of columns in the display. You can override this in the configuration file. For example, the following prints a right-justified string. .P @@ -2293,6 +2293,37 @@ depending on whether support for python function is available. contains a string describing this build. For example: .p POSIX (darwin) system using the g++ compiler and Python 3.9.6 - release variation +.IP "build-commit" 3 +contains the git commit hash (short form) of the build. For example: +.p + b74fcb1 +.IP "build-branch" 3 +contains the git branch name of the build. May be empty if not available. For example: +.p + dev-1.0.0 +.IP "build-time" 3 +contains the UTC timestamp of when the build was created. Format is +.B yyyy-MM-ddTHH:mm:ss. +For example: +.p + 2026-06-08T08:22:52 +.IP "build-source" 3 +contains either +.B local +or +.B github +depending on where the build was created. +.IP "build-number" 3 +contains the build number from GitHub Actions (github.run_number). Empty for local builds. +.IP "build-info" 3 +contains a composite string with all build information. For example: +.p + Built locally from commit 8bd11da on branch dev-1.0.0 at 2026-06-08T13:01:18 +.p +or +.p + Built on github (build 217) from commit 8bd11da of version 1.0.0 at 2026-06-08T12:40:01 +.p .SH EXAMPLES `ls -l` yields this: diff --git a/specs/docs/alu.md b/specs/docs/alu.md index 222a985..ca3c1c8 100644 --- a/specs/docs/alu.md +++ b/specs/docs/alu.md @@ -117,6 +117,18 @@ POSIX (darwin) system using the g++ compiler and Python 3.9.6 - release variatio ``` Others are `@cols`, which contains the number of columns in the terminal screen, and `@rows`, which contains the number of rows on that same screen. +Build information is also available via the following labels: +- `@build-commit` — the git commit hash (short form) of the build +- `@build-branch` — the git branch name (may be empty) +- `@build-time` — the UTC timestamp when the build was created (format: `yyyy-MM-ddTHH:mm:ss`) +- `@build-source` — either `local` or `github` +- `@build-number` — the GitHub Actions build number (empty for local builds) +- `@build-info` — a composite string with all build information, e.g.: + ``` + Built locally from commit 8bd11da on branch dev-1.0.0 at 2026-06-08T13:01:18 + Built on github (build 217) from commit 8bd11da of version 1.0.0 at 2026-06-08T12:40:01 + ``` + Additionally, the `@@` string stands for the entire input record. When rolling context is in effect (see [Streams and Records](streams.md#rolling-context)), `@@` always refers to the original input record. The `@!` string refers to the current record as affected by `CONTEXT`, which is the same as `@@` when no `CONTEXT` is active. The `@-n` and `@+n` syntax is an alternative to using that is effective within expressions. Note that reading beyond the input with `@+n` or `@-n` does not cause processing to stop, even if a `READSTOP` token is present in the specification. The following three specifications are equivalent: ``` diff --git a/specs/docs/onepage.md b/specs/docs/onepage.md index 0ee0ae1..3ac2289 100644 --- a/specs/docs/onepage.md +++ b/specs/docs/onepage.md @@ -200,6 +200,12 @@ There are some pre-configured labels that do not need to be explicitly defined: * platform - contains a string with the OS type, the compiler and the variation used to build *specs* * cols - contains the number of screen columns - useful for composed output placement. * rows - contains the number of screen rows. +* build-commit - contains the git commit hash (short form) of the build +* build-branch - contains the git branch name (may be empty) +* build-time - contains the UTC timestamp when the build was created (format: `yyyy-MM-ddTHH:mm:ss`) +* build-source - contains either `local` or `github` +* build-number - contains the GitHub Actions build number (empty for local builds) +* build-info - contains a composite string with all build information Examples ======== diff --git a/specs/src/ALUUnitTest.vcxproj b/specs/src/ALUUnitTest.vcxproj index a824806..80cfad0 100644 --- a/specs/src/ALUUnitTest.vcxproj +++ b/specs/src/ALUUnitTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/ProcessingTest.vcxproj b/specs/src/ProcessingTest.vcxproj index 3009b73..05a11b9 100644 --- a/specs/src/ProcessingTest.vcxproj +++ b/specs/src/ProcessingTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/TokenTest.vcxproj b/specs/src/TokenTest.vcxproj index 7fa3e9c..e60ce18 100644 --- a/specs/src/TokenTest.vcxproj +++ b/specs/src/TokenTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/build_info.targets b/specs/src/build_info.targets new file mode 100644 index 0000000..7e1e622 --- /dev/null +++ b/specs/src/build_info.targets @@ -0,0 +1,47 @@ + + + + + + + $([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss")) + local + $(SPECS_BUILD_SOURCE) + $(SPECS_BUILD_NUMBER) + + + + + + + + + + + + + + + $(BuildCommit.Trim()) + $(BuildBranch.Trim()) + + + + + $(SPECS_BRANCH) + + + + + + + + + + + + + + + + diff --git a/specs/src/cacheTest.vcxproj b/specs/src/cacheTest.vcxproj index 4709e89..a57f66c 100644 --- a/specs/src/cacheTest.vcxproj +++ b/specs/src/cacheTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/cli/tokens.cc b/specs/src/cli/tokens.cc index c627e1a..405b951 100644 --- a/specs/src/cli/tokens.cc +++ b/specs/src/cli/tokens.cc @@ -493,7 +493,7 @@ void parseSingleToken(std::vector *pVec, std::string arg, int argidx) /* Check for a configuration literal */ std::string key = arg.substr(1); - if ((arg[0]=='@') && (arg.length() > 1) && (configSpecLiteralExists(key))) { + if ((arg[0]=='@') && (arg.length() > 1) && (configSpecLiteralDefined(key))) { std::string literal = configSpecLiteralGet(key); pVec->insert(pVec->end(), Token(TokenListType__LITERAL, nullptr /* range */, diff --git a/specs/src/generate_build_info.py b/specs/src/generate_build_info.py new file mode 100644 index 0000000..567d396 --- /dev/null +++ b/specs/src/generate_build_info.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Generate build_info.h with current build information.""" + +import datetime +import os +import subprocess +import sys + + +def report_success(name, value): + print('Setting {} to "{}"'.format(name, value)) + + +def report_failure(name, exc): + sys.stderr.write("Failed to determine {}: {}\n".format(name, repr(exc))) + # If the failure came from a subprocess, surface the command's stderr too, + # since that usually explains *why* (e.g. git's "dubious ownership" error). + output = getattr(exc, "output", None) + if output: + if isinstance(output, bytes): + output = output.decode(errors="replace") + sys.stderr.write(" stdout: {}\n".format(output.strip())) + stderr = getattr(exc, "stderr", None) + if stderr: + if isinstance(stderr, bytes): + stderr = stderr.decode(errors="replace") + sys.stderr.write(" stderr: {}\n".format(stderr.strip())) + + +def run_git(name, args): + """Run a git command, capturing stderr so failures can be reported.""" + try: + value = subprocess.check_output( + ['git'] + args, + stderr=subprocess.PIPE + ).decode().strip() + if value: + report_success(name, value) + return value + except Exception as exc: + report_failure(name, exc) + return "" + + +# Get commit hash +build_commit = run_git("SPECS_BUILD_COMMIT", ['rev-parse', '--short', 'HEAD']) + +# Get branch name +build_branch = run_git("SPECS_BUILD_BRANCH", ['branch', '--show-current']) + +# Fall back to the SPECS_BRANCH environment variable (set by release.yml) +# since `git branch --show-current` is empty on a detached HEAD / tag checkout. +if build_branch == "": + build_branch = os.environ.get("SPECS_BRANCH", "") + if build_branch: + report_success("SPECS_BUILD_BRANCH (from SPECS_BRANCH env)", build_branch) + +# Get UTC build time +build_time = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") +report_success("SPECS_BUILD_TIME", build_time) + +# Get build source and number from environment +build_source = os.environ.get("SPECS_BUILD_SOURCE", "local") +report_success("SPECS_BUILD_SOURCE", build_source) +build_number = os.environ.get("SPECS_BUILD_NUMBER", "") +report_success("SPECS_BUILD_NUMBER", build_number) + +# Write the header file +with open("utils/build_info.h", "w") as f: + f.write('#define SPECS_BUILD_COMMIT "{}"\n'.format(build_commit)) + f.write('#define SPECS_BUILD_BRANCH "{}"\n'.format(build_branch)) + f.write('#define SPECS_BUILD_TIME "{}"\n'.format(build_time)) + f.write('#define SPECS_BUILD_SOURCE "{}"\n'.format(build_source)) + f.write('#define SPECS_BUILD_NUMBER "{}"\n'.format(build_number)) + +print("Generated utils/build_info.h") diff --git a/specs/src/itemTest.vcxproj b/specs/src/itemTest.vcxproj index e6234ea..228b433 100644 --- a/specs/src/itemTest.vcxproj +++ b/specs/src/itemTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/processing/Config.cc b/specs/src/processing/Config.cc index 8a9e5a7..a08d9f4 100644 --- a/specs/src/processing/Config.cc +++ b/specs/src/processing/Config.cc @@ -19,6 +19,7 @@ #include "utils/PythonIntf.h" #include "utils/aluRegex.h" #include "utils/aluFunctions.h" +#include "utils/build_info.h" #include "Config.h" #define STRINGIFY2(x) #x @@ -212,6 +213,36 @@ void readConfigurationFile() if (0==ExternalLiterals.count("rows")) { ExternalLiterals["rows"] = getTerminalRowsAndColumns(true); } + + // Build information + ExternalLiterals["build-commit"] = dequote(STRINGIFY(SPECS_BUILD_COMMIT)); + ExternalLiterals["build-branch"] = dequote(STRINGIFY(SPECS_BUILD_BRANCH)); + ExternalLiterals["build-time"] = dequote(STRINGIFY(SPECS_BUILD_TIME)); + ExternalLiterals["build-source"] = dequote(STRINGIFY(SPECS_BUILD_SOURCE)); + ExternalLiterals["build-number"] = dequote(STRINGIFY(SPECS_BUILD_NUMBER)); + + // Compose build-info + std::string build_info = "Built "; + if (ExternalLiterals["build-source"] == "github") { + build_info += "on github"; + if (!ExternalLiterals["build-number"].empty()) { + build_info += " (build " + ExternalLiterals["build-number"] + ")"; + } + } else { + build_info += "locally"; + } + if (!ExternalLiterals["build-commit"].empty()) { + build_info += " from commit " + ExternalLiterals["build-commit"]; + if (!ExternalLiterals["build-branch"].empty()) { + if (ExternalLiterals["build-branch"]=="dev" || ExternalLiterals["build-branch"]=="stable") { + build_info += " of version " + ExternalLiterals["version"]; + } else { + build_info += " on branch " + ExternalLiterals["build-branch"]; + } + } + } + build_info += " at " + ExternalLiterals["build-time"]; + ExternalLiterals["build-info"] = build_info; } bool configSpecLiteralExists(std::string& key) @@ -220,6 +251,12 @@ bool configSpecLiteralExists(std::string& key) return it != ExternalLiterals.end() && !it->second.empty(); } +bool configSpecLiteralDefined(std::string& key) +{ + auto it = ExternalLiterals.find(key); + return it != ExternalLiterals.end(); +} + std::string& configSpecLiteralGet(std::string& key) { return ExternalLiterals[key]; diff --git a/specs/src/processing/Config.h b/specs/src/processing/Config.h index 34f7afb..85da620 100644 --- a/specs/src/processing/Config.h +++ b/specs/src/processing/Config.h @@ -65,6 +65,8 @@ void readConfigurationFile(); bool configSpecLiteralExists(std::string& key); +bool configSpecLiteralDefined(std::string& key); + std::string& configSpecLiteralGet(std::string& key); std::string& configSpecLiteralGetWithDefault(std::string& key, std::string& _default); diff --git a/specs/src/readWriteTest.vcxproj b/specs/src/readWriteTest.vcxproj index e13b871..13e3c4c 100644 --- a/specs/src/readWriteTest.vcxproj +++ b/specs/src/readWriteTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/setup.py b/specs/src/setup.py index 3cac026..5f6977e 100644 --- a/specs/src/setup.py +++ b/specs/src/setup.py @@ -186,11 +186,19 @@ def python_search(arg): LIBOBJS = $(CCSRC:.cc=.{}) TESTOBJS = $(TESTSRC:.cc=.{}) +# build_info.h is regenerated only when one of the core objects would be +# rebuilt (Config.o is excluded to avoid a cycle, since Config.cc includes +# build_info.h). This keeps an up-to-date tree a no-op instead of forcing a +# spurious header regeneration, Config.o recompile and relink on every build. +BUILD_INFO_DEPS = $(filter-out processing/Config.o processing/Config.obj,$(LIBOBJS)) + #default goal some: directories $(EXE_DIR)/specs $(EXE_DIR)/specs-autocomplete all: directories $(TEST_EXES) +specs: directories $(EXE_DIR)/specs + %.obj : %.cc $(CXX) $(CPPFLAGS) /Fo$@ /c $< @@ -212,6 +220,11 @@ def python_search(arg): body2 = \ """ +.PHONY: specs + +utils/build_info.h: $(BUILD_INFO_DEPS) + @python3 generate_build_info.py + run_tests: $(TEST_EXES) $(EXE_DIR)/TokenTest $(EXE_DIR)/ProcessingTest @@ -762,6 +775,10 @@ def python_search(arg): "python3 $(TESTS_DIR)/recfm_tests.py\n\tpython3 $(TESTS_DIR)/pytest.py" ) +# Generate build_info.h (so it exists before the first compile; it is +# regenerated on every build by the utils/build_info.h Makefile target) +subprocess.call([sys.executable, "generate_build_info.py"]) + with open("Makefile", "w") as makefile: makefile.write("CXX={}\n".format(cxx)) makefile.write("LINKER={}\n".format("link.exe" if (compiler=="VS") else cxx)) diff --git a/specs/src/specs.vcxproj b/specs/src/specs.vcxproj index 33a40d0..a814532 100644 --- a/specs/src/specs.vcxproj +++ b/specs/src/specs.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/test/ALUUnitTest.cc b/specs/src/test/ALUUnitTest.cc index 53023e7..8ed7c94 100644 --- a/specs/src/test/ALUUnitTest.cc +++ b/specs/src/test/ALUUnitTest.cc @@ -1137,6 +1137,7 @@ int runALUUnitTests11(unsigned int onlyTest) VERIFY_EXPR_RES("substitute('Just the place for a snark',' ','','u')", "Just the place for a snark"); VERIFY_EXPR_RES("substitute('Just the place for a snark',' ','','U')", "Justtheplaceforasnark"); VERIFY_EXPR_RES("substitute('Just the place for a snark',' ','_','U')", "Just_the_place_for_a_snark"); + VERIFY_EXPR_RES("substitute('Just the place for a snark',' ','_')", "Just_the place for a snark"); VERIFY_EXPR_RES("sfield('Where hae\tya been',0,'')","sfield: Called with count equal to zero"); VERIFY_EXPR_RES("sfield('Where hae\tya been',1,'')","Where hae"); diff --git a/specs/src/timeTest.vcxproj b/specs/src/timeTest.vcxproj index 3c8f77f..c24bd88 100644 --- a/specs/src/timeTest.vcxproj +++ b/specs/src/timeTest.vcxproj @@ -31,6 +31,7 @@ true + diff --git a/specs/src/utils/aluFunctions.cc b/specs/src/utils/aluFunctions.cc index 0eb302c..9fdabed 100644 --- a/specs/src/utils/aluFunctions.cc +++ b/specs/src/utils/aluFunctions.cc @@ -1689,8 +1689,7 @@ PValue AluFunc_substitute(PValue pSrc, PValue pSearchString, PValue pSubstitute, ASSERT_NOT_ELIDED(pSearchString,2,needle); ASSERT_NOT_ELIDED(pSubstitute,3,subst); std::string res = pSrc->getStr(); - ALUInt count = ARG_INT_WITH_DEFAULT(pMax,1); - if (pMax->getStr()=="U") count = MAX_ALUInt; + ALUInt count = (ARG_STR_WITH_DEFAULT(pMax,"") == "U") ? MAX_ALUInt : ARG_INT_WITH_DEFAULT(pMax,1); size_t findRet = 0; diff --git a/specs/tests/valgrind_unit_tests.py b/specs/tests/valgrind_unit_tests.py index 7d5bf86..2c780aa 100644 --- a/specs/tests/valgrind_unit_tests.py +++ b/specs/tests/valgrind_unit_tests.py @@ -1,6 +1,6 @@ import sys, memcheck, argparse -count_ALU_tests = 843 +count_ALU_tests = 844 count_processing_tests = 277 count_token_tests = 17