#!/usr/bin/env python3 """Validate toolchain pins and Go 1.20 compatibility of core/win7 modules.""" import json import os import pathlib import re import subprocess import sys MAX_LEGACY_GO_VERSION = (1, 20) MODERN_TOOLCHAIN = "go1.25.0" LEGACY_TOOLCHAIN = "go1.20.14" def version_tuple(value): match = re.fullmatch(r"(\d+)\.(\d+)(?:\.(\d+))?", value) if not match: raise ValueError("invalid Go version {!r}".format(value)) return tuple(int(part or 0) for part in match.groups()) def decode_json_stream(text): decoder = json.JSONDecoder() index = 0 while index < len(text): while index < len(text) and text[index].isspace(): index += 1 if index >= len(text): return value, index = decoder.raw_decode(text, index) yield value def read_text(path): return path.read_text(encoding="utf-8") def go_directive(path): match = re.search(r"(?m)^go\s+(\d+\.\d+(?:\.\d+)?)\s*$", read_text(path)) if not match: raise ValueError("{} has no go directive".format(path)) return match.group(1) def required_version(path, module_path): pattern = r"(?m)^\s*{}\s+(v[^\s]+)".format(re.escape(module_path)) match = re.search(pattern, read_text(path)) if not match: raise ValueError( "{} does not require {}".format(path, module_path) ) return match.group(1) def run_go(cwd, arguments, go_work, toolchain): environment = os.environ.copy() environment["GOWORK"] = str(go_work) if go_work else "off" environment["GOTOOLCHAIN"] = toolchain result = subprocess.run( ["go"] + arguments, cwd=str(cwd), env=environment, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, ) if result.returncode != 0: print(result.stderr, file=sys.stderr, end="") raise SystemExit(result.returncode) return result.stdout def workspace_modules(workspace, toolchain): document = json.loads( run_go( workspace.parent, ["work", "edit", "-json"], workspace, toolchain, ) ) return { (workspace.parent / entry["DiskPath"]).resolve() for entry in document.get("Use", []) } def display_path(path, repo_root): try: return str(path.relative_to(repo_root)) except ValueError: return str(path) def workspace_layout_violation(workspace, actual, expected, repo_root): if actual == expected: return None actual_names = sorted(display_path(path, repo_root) for path in actual) expected_names = sorted(display_path(path, repo_root) for path in expected) return "{} contains {}, want {}".format( display_path(workspace, repo_root), actual_names, expected_names, ) def validate_workspace_layout(repo_root): root_workspace = repo_root / "go.work" win7_workspace = repo_root / "app-win7" / "go.work" expected = { root_workspace: { (repo_root / "app-modern").resolve(), (repo_root / "core").resolve(), }, win7_workspace: { (repo_root / "app-win7").resolve(), (repo_root / "core").resolve(), }, } toolchains = { root_workspace: MODERN_TOOLCHAIN, win7_workspace: LEGACY_TOOLCHAIN, } violations = [] for workspace, expected_modules in expected.items(): actual_modules = workspace_modules(workspace, toolchains[workspace]) violation = workspace_layout_violation( workspace, actual_modules, expected_modules, repo_root, ) if violation: violations.append(violation) return violations def resolved_module_version(cwd, workspace, toolchain, module_path): document = json.loads( run_go( cwd, ["list", "-m", "-json", module_path], workspace, toolchain, ) ) return document.get("Version", "") def module_version_violation(label, module_path, actual, expected): if actual == expected: return None return "{} workspace resolves {} {}, want {}".format( label, module_path, actual, expected, ) def validate_workspace_gio_versions(repo_root): checks = ( ( "modern", repo_root / "app-modern", repo_root / "go.work", MODERN_TOOLCHAIN, "v0.10.1", ), ( "win7", repo_root / "app-win7", repo_root / "app-win7" / "go.work", LEGACY_TOOLCHAIN, "v0.6.0", ), ) violations = [] for label, cwd, workspace, toolchain, expected in checks: actual = resolved_module_version( cwd, workspace, toolchain, "gioui.org", ) violation = module_version_violation( label, "gioui.org", actual, expected, ) if violation: violations.append(violation) return violations def validate_pins(repo_root): expected_go_directives = { repo_root / "go.work": "1.25.0", repo_root / "core" / "go.mod": "1.20", repo_root / "app-modern" / "go.mod": "1.25.0", repo_root / "app-win7" / "go.mod": "1.20", repo_root / "app-win7" / "go.work": "1.20", } violations = [] for path, expected in expected_go_directives.items(): actual = go_directive(path) if actual != expected: violations.append( "{} go directive is {}, want {}".format( path.relative_to(repo_root), actual, expected ) ) gio_pins = { repo_root / "app-modern" / "go.mod": "v0.10.1", repo_root / "app-win7" / "go.mod": "v0.6.0", } for path, expected in gio_pins.items(): actual = required_version(path, "gioui.org") if actual != expected: violations.append( "{} pins gioui.org {}, want {}".format( path.relative_to(repo_root), actual, expected ) ) return violations def validate_go20_toolchain(repo_root): output = run_go( repo_root, ["version"], None, LEGACY_TOOLCHAIN, ).strip() if "go1.20.14" not in output: return ["compatibility scan uses {!r}, want go1.20.14".format(output)] return [] def validate_module_versions(repo_root): module_sets = ( ("core", repo_root / "core", None, LEGACY_TOOLCHAIN), ( "win7", repo_root / "app-win7", repo_root / "app-win7" / "go.work", LEGACY_TOOLCHAIN, ), ) violations = [] checked = 0 for label, cwd, go_work, toolchain in module_sets: output = run_go( cwd, ["list", "-m", "-json", "all"], go_work, toolchain, ) for module in decode_json_stream(output): checked += 1 go_version = module.get("GoVersion") if not go_version: continue if version_tuple(go_version)[:2] > MAX_LEGACY_GO_VERSION: violations.append( "{} module {} declares go {}, exceeds 1.20".format( label, module["Path"], go_version ) ) return violations, checked def self_check(): if version_tuple("1.20")[:2] > MAX_LEGACY_GO_VERSION: raise AssertionError("Go 1.20 should be accepted") if version_tuple("1.20.14")[:2] > MAX_LEGACY_GO_VERSION: raise AssertionError("Go 1.20.14 should be accepted") if not version_tuple("1.21")[:2] > MAX_LEGACY_GO_VERSION: raise AssertionError("Go 1.21 should be rejected") fake_root = pathlib.Path("/repo") fake_workspace = fake_root / "go.work" fake_expected = {fake_root / "core", fake_root / "app-modern"} if workspace_layout_violation( fake_workspace, fake_expected, fake_expected, fake_root, ): raise AssertionError("matching workspace layout should be accepted") if not workspace_layout_violation( fake_workspace, fake_expected | {fake_root / "app-win7"}, fake_expected, fake_root, ): raise AssertionError("unexpected workspace module should be rejected") if module_version_violation( "win7", "gioui.org", "v0.6.0", "v0.6.0", ): raise AssertionError("matching Gio version should be accepted") if not module_version_violation( "win7", "gioui.org", "v0.10.1", "v0.6.0", ): raise AssertionError("mismatched Gio version should be rejected") def main(): self_check() repo_root = pathlib.Path(__file__).resolve().parents[1] violations = validate_pins(repo_root) violations.extend(validate_workspace_layout(repo_root)) violations.extend(validate_workspace_gio_versions(repo_root)) violations.extend(validate_go20_toolchain(repo_root)) module_violations, checked = validate_module_versions(repo_root) violations.extend(module_violations) if violations: for violation in violations: print("ERROR: " + violation, file=sys.stderr) raise SystemExit(1) print( "Go version check passed: workspace isolation and pins valid; " "{} module records are Go 1.20-compatible.".format( checked ) ) if __name__ == "__main__": main()