2026-07-16 15:51:34 +08:00
|
|
|
#!/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)
|
2026-07-16 21:27:52 +08:00
|
|
|
MODERN_TOOLCHAIN = "go1.25.0"
|
|
|
|
|
LEGACY_TOOLCHAIN = "go1.20.14"
|
2026-07-16 15:51:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 21:27:52 +08:00
|
|
|
def run_go(cwd, arguments, go_work, toolchain):
|
2026-07-16 15:51:34 +08:00
|
|
|
environment = os.environ.copy()
|
|
|
|
|
environment["GOWORK"] = str(go_work) if go_work else "off"
|
2026-07-16 21:27:52 +08:00
|
|
|
environment["GOTOOLCHAIN"] = toolchain
|
2026-07-16 15:51:34 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 21:27:52 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 15:51:34 +08:00
|
|
|
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):
|
2026-07-16 21:27:52 +08:00
|
|
|
output = run_go(
|
|
|
|
|
repo_root,
|
|
|
|
|
["version"],
|
|
|
|
|
None,
|
|
|
|
|
LEGACY_TOOLCHAIN,
|
|
|
|
|
).strip()
|
2026-07-16 15:51:34 +08:00
|
|
|
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 = (
|
2026-07-16 21:27:52 +08:00
|
|
|
("core", repo_root / "core", None, LEGACY_TOOLCHAIN),
|
2026-07-16 15:51:34 +08:00
|
|
|
(
|
|
|
|
|
"win7",
|
|
|
|
|
repo_root / "app-win7",
|
|
|
|
|
repo_root / "app-win7" / "go.work",
|
2026-07-16 21:27:52 +08:00
|
|
|
LEGACY_TOOLCHAIN,
|
2026-07-16 15:51:34 +08:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
violations = []
|
|
|
|
|
checked = 0
|
|
|
|
|
|
2026-07-16 21:27:52 +08:00
|
|
|
for label, cwd, go_work, toolchain in module_sets:
|
2026-07-16 15:51:34 +08:00
|
|
|
output = run_go(
|
|
|
|
|
cwd,
|
|
|
|
|
["list", "-m", "-json", "all"],
|
|
|
|
|
go_work,
|
2026-07-16 21:27:52 +08:00
|
|
|
toolchain,
|
2026-07-16 15:51:34 +08:00
|
|
|
)
|
|
|
|
|
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")
|
2026-07-16 21:27:52 +08:00
|
|
|
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")
|
2026-07-16 15:51:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
self_check()
|
|
|
|
|
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
|
|
|
|
violations = validate_pins(repo_root)
|
2026-07-16 21:27:52 +08:00
|
|
|
violations.extend(validate_workspace_layout(repo_root))
|
|
|
|
|
violations.extend(validate_workspace_gio_versions(repo_root))
|
2026-07-16 15:51:34 +08:00
|
|
|
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(
|
2026-07-16 21:27:52 +08:00
|
|
|
"Go version check passed: workspace isolation and pins valid; "
|
|
|
|
|
"{} module records are Go 1.20-compatible.".format(
|
2026-07-16 15:51:34 +08:00
|
|
|
checked
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|