From 3e407cdefa14c661e7f7fe6b165457d7143963fd Mon Sep 17 00:00:00 2001 From: "Bernhard M. Wiedemann" Date: Tue, 25 Aug 2026 14:39:51 +0200 Subject: [PATCH 1/2] utils: Record a last-commit entry for every documentation page generate_last_commit_file.py keyed core_modules_with_last_commit.json by the basename of each directory containing a .html file, while get_git_commit_from_file() looks entries up by page name. Pages whose name differs from their directory therefore never matched: r.watershed (html in front/), r3.mapcalc (in the r.mapcalc directory), the wxGUI.* pages (all in gui/wxpython/docs/), the intro and driver pages (databaseintro in db/, helptext in lib/init/, ...) and the Markdown-only development pages in doc/, which have no .html file at all. In a build from a release tarball these pages fell back to the source directory mtime, which is the build time, so they said "Accessed: " instead of "Latest change: in commit: " and differed between two builds of the same source. Key the JSON by page name instead, one entry per .html or .md file, keeping the last commit of the directory the page lives in as the recorded commit so that existing entries and the output of builds with Git history are unchanged. Sort the walk so that the few duplicate page names (r.drain in raster/ and scripts/, the example pages under doc/) resolve to the same entry in every run. This grows the file from 586 to 651 entries. mkhtml.py had a second bug hiding some of the same pages: the "" and "" comments overwrote pgm with the page title (e.g. "LRS" for the lrs page), which is not a JSON key, so even pages with a matching entry missed the lookup. Keep the title in a separate pgm_title used for the generated header and source code section, whose rendered output is unchanged, and leave pgm as the page name for the commit and addon path lookups. Together with the release workflow, which already runs generate_last_commit_file.py and ships the JSON in the release tarball, this makes all the previously date-stamped pages (71 html, 71 man, 74 markdown in GRASS 8.5.0) show the real last-change date and build reproducibly. This was found and fixed while working on reproducible builds for openSUSE, with the analysis and patch drafted by an AI assistant (Claude) and reviewed by the author. --- utils/generate_last_commit_file.py | 39 +++++++++++------- utils/mkhtml.py | 14 +++++-- utils/test_generate_last_commit_file.py | 54 +++++++++++++------------ 3 files changed, 63 insertions(+), 44 deletions(-) diff --git a/utils/generate_last_commit_file.py b/utils/generate_last_commit_file.py index e9d91bad3a4..04864f5431d 100644 --- a/utils/generate_last_commit_file.py +++ b/utils/generate_last_commit_file.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ -Script for creating an core_modules_with_last_commit.json file contains -all core modules with their last commit. Used by GitHub "Create new -release draft" action workflow. +Script for creating a core_modules_with_last_commit.json file which +contains every documentation page with its last commit. Used by GitHub +"Create new release draft" action workflow. JSON file structure: @@ -33,7 +33,8 @@ def get_last_commit(src_dir): - """Generate core modules JSON object with the following structure + """Generate JSON object with an entry per documentation page with the + following structure "r.pack": { "commit": "547ff44e6aecfb4c9cbf6a4717fc14e521bec0be", @@ -43,17 +44,26 @@ def get_last_commit(src_dir): commit key value is commit hash date key value is author date + Entries are keyed by the name of each .html or .md documentation page, + so that pages whose name differs from their directory (r3.mapcalc, + r.watershed, the wxGUI.* pages, the intro pages, ...) and pages which + only exist as Markdown (the doc/ development pages) get an entry too. + The recorded commit is the last commit touching the directory the page + lives in, matching what a build with Git history shows for that page. + :param str src_dir: root source code dir - :return JSON obj result: core modules with last commit and commit - date + :return JSON obj result: documentation pages with last commit and + commit date """ result = {} - join_sep = "," if not shutil.which("git"): sys.exit("Git command was not found. Please install it.") - for root, _, files in os.walk(src_dir): - if ".html{}".format(join_sep) not in join_sep.join(files) + join_sep: + for root, dirs, files in os.walk(src_dir): + # sort + skip hidden dirs + dirs[:] = sorted(d for d in dirs if not d.startswith(".")) + pages = [f for f in files if f.endswith((".html", ".md"))] + if not pages: continue rel_path = os.path.relpath(root) process_result = subprocess.run( @@ -79,10 +89,11 @@ def get_last_commit(src_dir): f"Cannot parse output from git log for '{rel_path}': " f"{text} because {error}" ) - result[os.path.basename(rel_path)] = { - "commit": commit, - "date": date, - } + for page in sorted(pages): + result[os.path.splitext(page)[0]] = { + "commit": commit, + "date": date, + } else: sys.exit(process_result.stderr.decode()) return result @@ -99,7 +110,7 @@ def main(): ), "w", ) as f: - json.dump(get_last_commit(src_dir), f, indent=4) + json.dump(get_last_commit(src_dir), f, indent=4, sort_keys=True) if __name__ == "__main__": diff --git a/utils/mkhtml.py b/utils/mkhtml.py index e8e55bbedeb..8da9f1d86cc 100644 --- a/utils/mkhtml.py +++ b/utils/mkhtml.py @@ -335,10 +335,16 @@ def update_toc(data): # process header src_data = read_file(src_file) +# The meta page comments override the page title used in the generated +# header and source code section. The title must stay separate from pgm, +# which is the page name used to look up the last commit and the addon +# path, where the overridden title (e.g. "LRS" for the lrs page) would +# not match. +pgm_title = pgm name = re.search(r"()", src_data, re.IGNORECASE) pgm_desc = "GRASS Reference Manual" if name: - pgm = name.group(2).strip().split("-", 1)[0].strip() + pgm_title = name.group(2).strip().split("-", 1)[0].strip() name_desc = re.search( r"()", src_data, re.IGNORECASE ) @@ -346,7 +352,7 @@ def update_toc(data): pgm_desc = name_desc.group(2).strip() desc = re.search(r"()", src_data, re.IGNORECASE) if desc: - pgm = desc.group(2).strip() + pgm_title = desc.group(2).strip() header_tmpl = string.Template(header_base + header_nopgm) elif not pgm_desc: header_tmpl = string.Template(header_base + header_pgm) @@ -380,7 +386,7 @@ def update_toc(data): ",".join(new_keywords_paths), ) if not re.search(r"", tmp_data, re.IGNORECASE): - sys.stdout.write(header_tmpl.substitute(PGM=pgm, PGM_DESC=pgm_desc)) + sys.stdout.write(header_tmpl.substitute(PGM=pgm_title, PGM_DESC=pgm_desc)) if tmp_data: header_logo_img_el = 'GRASS logo' @@ -510,7 +516,7 @@ def to_title(name): sys.stdout.write( sourcecode.substitute( URL_SOURCE=url_source, - PGM=pgm, + PGM=pgm_title, URL_LOG=url_log, DATE_TAG=date_tag, ) diff --git a/utils/test_generate_last_commit_file.py b/utils/test_generate_last_commit_file.py index 388f9632da3..21429e8f732 100644 --- a/utils/test_generate_last_commit_file.py +++ b/utils/test_generate_last_commit_file.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ -Script for testing an core_modules_with_last_commit.json file contains -all core modules with their last commit. Used by GitHub "Additional Checks" -action workflow. +Script for testing that the core_modules_with_last_commit.json file +contains every documentation page with its last commit. Used by GitHub +"Additional Checks" action workflow. Python lib dependencies: @@ -47,33 +47,36 @@ def test_json_file_is_not_empty(read_json_file): assert len(read_json_file) > 0 +# Pairs of documentation page name and the directory its source lives in. +# Besides plain tools, they cover pages whose name differs from their +# directory name and pages which only exist as Markdown, which both rely +# on entries being keyed by page name. +PAGES = [ + ("v.surf.rst", os.path.join("vector", "v.surf.rst")), + ("r.info", os.path.join("raster", "r.info")), + ("r3.mapcalc", os.path.join("raster", "r.mapcalc")), + ("r.watershed", os.path.join("raster", "r.watershed", "front")), + ("wxGUI.components", os.path.join("gui", "wxpython", "docs")), + ("databaseintro", "db"), + ("style_guide", os.path.join("doc", "development")), + ("python_intro", "doc"), +] + + @pytest.mark.depends(on=["test_json_file_is_not_empty"]) -@pytest.mark.parametrize( - "core_module_path", - [ - os.path.join("vector", "v.surf.rst"), - os.path.join("raster", "r.info"), - ], -) -def test_core_modules_in_json_file(read_json_file, core_module_path): - core_module = os.path.basename(core_module_path) - assert core_module in read_json_file +@pytest.mark.parametrize(("page", "page_path"), PAGES) +def test_pages_in_json_file(read_json_file, page, page_path): + assert page in read_json_file @pytest.mark.depends( on=[ "test_json_file_is_not_empty", - "test_core_modules_in_json_file", + "test_pages_in_json_file", ] ) -@pytest.mark.parametrize( - "core_module_path", - [ - os.path.join("vector", "v.surf.rst"), - os.path.join("raster", "r.info"), - ], -) -def test_compare_json_file_data(read_json_file, core_module_path): +@pytest.mark.parametrize(("page", "page_path"), PAGES) +def test_compare_json_file_data(read_json_file, page, page_path): # Get Git commit and commit date from local Git process_result = subprocess.run( [ @@ -81,13 +84,12 @@ def test_compare_json_file_data(read_json_file, core_module_path): "log", "-1", f"--format=%H,{COMMIT_DATE_FORMAT}", - core_module_path, + page_path, ], capture_output=True, check=True, ) # --format=%H,COMMIT_DATE_FORMAT commit hash,author date commit, date = process_result.stdout.decode().strip().split(",") - core_module = os.path.basename(core_module_path) # Compare commit and commit date - assert read_json_file[core_module]["commit"] == commit - assert read_json_file[core_module]["date"] == date + assert read_json_file[page]["commit"] == commit + assert read_json_file[page]["date"] == date From ef28ed9071f1a9a167272e20cbad819a8220310a Mon Sep 17 00:00:00 2001 From: "Bernhard M. Wiedemann" Date: Tue, 25 Aug 2026 15:35:33 +0200 Subject: [PATCH 2/2] man: Give the manual gallery a deterministic image order Images in manual_gallery.html were sorted by the manual page they belong to alone, so multiple images of the same page (e.g. the three wxGUI.tplot screenshots) kept their os.listdir() order, which is the filesystem order and differs between builds. Sort by image name within one manual page to make the gallery reproducible. This patch was done while working on reproducible builds for openSUSE. --- man/build_manual_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/build_manual_gallery.py b/man/build_manual_gallery.py index cc78157bb33..c31ee46f0a4 100755 --- a/man/build_manual_gallery.py +++ b/man/build_manual_gallery.py @@ -200,7 +200,7 @@ def main(ext): if ext == "html": output.write(header_graphical_index_tmpl) output.write('
    \n') - for image, filename in sorted(img_files.items(), key=operator.itemgetter(1)): + for image, filename in sorted(img_files.items(), key=operator.itemgetter(1, 0)): name = get_module_name(filename, ext) title = title_from_names(name, image) if ext == "html":