From 0a3d946ffa4729f12ec5cecca1f684d1af62bb8f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 3 Nov 2026 11:41:33 -0500 Subject: [PATCH] Latest merge from Hunter. Also a couple files I forgot to add yesterday. --- CONTRIBUTING.md | 10 +- README.md | 44 +++++++++ internal/version/version.go | 2 +- tests/test_build_script.py | 184 ++++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 tests/test_build_script.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0304292..533b9de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,8 +36,7 @@ Record changed behavior and remaining acceptance gaps in the contribution descri Use Go 1.24 or newer, CGO, `pkg-config`, and development libraries for GTK3, AT-SPI, GStreamer, and X11. Braille builds also require BRLTTY/BrlAPI and liblouis. The Arch `PKGBUILD` lists runtime and build dependencies. ```sh -go build -o build/cthulhu ./cmd/cthulhu -go build -tags brlapi -o build/cthulhu-braille ./cmd/cthulhu-braille +./build-cthulhu.sh --output-dir build go test ./... -count=1 go test -race ./... -count=1 go test -tags brlapi ./... -count=1 @@ -46,6 +45,13 @@ go build ./cmd/... go run ./cmd/parityledger validate testdata/parity-ledger.csv ``` +The build script selects or bootstraps Go without changing the system +installation; see [Building](README.md#building) for packaging, cache and +offline controls. Direct `go test`, `go vet` and other Go commands still need +a suitable Go executable on `PATH` (Go 1.21+ can select newer toolchains). +Build-script regression checks run with `python3 tests/test_build_script.py -v`; +they use isolated fake tools and require no network or running reader. + Start verification with the affected packages; run broader checks when the change affects shared behavior or before submission where practical. Format edited Go files with `gofmt`; run `shellcheck` for edited shell scripts. Use deterministic tests and synchronization instead of timing sleeps. Keep fixtures generic, without real usernames or personal identifiers. ### Version update pre-commit hook diff --git a/README.md b/README.md index c6cf15e..5601a02 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,50 @@ Cthulhu is a Linux screen reader written in Go. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). +## Building + +Run `./build-cthulhu.sh` to build `cthulhu` and `cthulhu-braille` in the +checkout root. The script works from any directory. It uses Go's automatic +toolchain selection with Go 1.21 or newer. If Go is missing or older, it +downloads the version required by `go.mod`, verifies the archive's SHA-256, +and caches it under `${XDG_CACHE_HOME:-$HOME/.cache}/cthulhu/toolchains`. +It does not replace system Go or require root. + +Install a C compiler, `pkg-config`, and the development libraries for GTK3, +AT-SPI, GLib/GIO, GStreamer, X11, XTest, XFixes, XRes, xkbcommon, liblouis, +and BRLTTY/BrlAPI first. The script checks the pkg-config dependencies; +BrlAPI headers and its library are checked by the compiler during the build. +Bootstrapping Go additionally needs `curl`, `tar`, and `sha256sum`. +See [Contributing](CONTRIBUTING.md#development-and-architecture) and the +Arch package recipes under `distro-packages/Arch-Linux/` for dependencies. + +For packaging, put both binaries in a separate directory and pass build flags +after `--`: + +```sh +./build-cthulhu.sh --output-dir build -- -trimpath -mod=readonly +``` + +Relative output paths start at the caller's directory. `GOFLAGS`, `GOCACHE`, +`GOMODCACHE`, `GOTMPDIR`, and CGO compiler/linker settings are inherited. +CGO defaults to enabled and cannot be disabled. The Braille helper always +uses the `brlapi` build tag. Package recipes install both binaries beside +one another and stage documentation and other resources separately. + +`GOTOOLCHAIN` defaults to `auto`; set it explicitly to `local` for builds +that must use an installed toolchain: + +```sh +GOTOOLCHAIN=local GOPROXY=off ./build-cthulhu.sh --output-dir build -- -mod=readonly +install -Dm755 build/cthulhu "$pkgdir/usr/bin/cthulhu" +install -Dm755 build/cthulhu-braille "$pkgdir/usr/bin/cthulhu-braille" +``` + +For offline builds, provision a suitable Go version and module cache in +advance. Build inside the target distribution to link against its native +libraries; downloading Go does not supply those libraries. `--help` lists +the script's options without checking dependencies or accessing the network. + ## Start with the laptop keyboard layout Run `cthulhu -l` to use the laptop layout with Caps Lock as the Cthulhu diff --git a/internal/version/version.go b/internal/version/version.go index 9a21679..24ae986 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -22,7 +22,7 @@ import "strings" const Name = "Cthulhu" const Version = "2026.11.03" -const Channel = "hunter" +const Channel = "testing" var Build = Version diff --git a/tests/test_build_script.py b/tests/test_build_script.py new file mode 100644 index 0000000..30ee2ab --- /dev/null +++ b/tests/test_build_script.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Exercise build/toolchain boundaries without network access or a reader.""" + +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest + + +MOCK_TOOL = r'''#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import shutil +import sys + +tool = Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ["BUILD_LOG"], "a") as stream: + stream.write(json.dumps([tool, args, os.getcwd(), { + name: os.environ.get(name) for name in + ("GOTOOLCHAIN", "GOFLAGS", "CGO_ENABLED", "CGO_CFLAGS", "GOMODCACHE") + }]) + "\n") +if tool == "uname": + print("Linux" if args == ["-s"] else "x86_64") +elif tool == "pkg-config": + sys.exit(1 if args[-1] == os.environ.get("MISSING_LIBRARY") else 0) +elif tool == "curl": + if args[-1].endswith(".sha256"): + print(os.environ["ARCHIVE_SHA"]) + else: + shutil.copyfile(os.environ["ARCHIVE_PATH"], args[args.index("--output") + 1]) +elif tool in ("go", "bootstrap-go"): + if args == ["version"]: + version = "1.24.0" if tool == "bootstrap-go" else os.environ["INSTALLED_GO"] + print("go version go" + version + " linux/amd64") + elif args[0] == "build": + if args[-1].endswith("cthulhu-braille") and os.environ.get("FAIL_BRAILLE"): + sys.exit(2) + Path(args[args.index("-o") + 1]).touch() +''' + + +class BuildScriptTests(unittest.TestCase): + def setUp(self): + self.tempDir = tempfile.TemporaryDirectory(prefix="cthulhu-build-test-") + self.addCleanup(self.tempDir.cleanup) + self.root = Path(self.tempDir.name) + self.project = self.root / "checkout" + self.project.mkdir() + source = Path(__file__).resolve().parents[1] / "build-cthulhu.sh" + self.script = self.project / source.name + shutil.copyfile(source, self.script) + (self.project / "go.mod").write_text("module example.invalid/reader\n\ngo 1.24.0\n") + self.caller = self.root / "caller directory" + self.caller.mkdir() + self.binDir = self.root / "bin" + self.binDir.mkdir() + for name in ("go", "curl", "pkg-config", "uname"): + toolPath = self.binDir / name + toolPath.write_text(MOCK_TOOL) + toolPath.chmod(0o755) + # Give the downloaded Go its own name for log assertions. + bootstrap = MOCK_TOOL.replace('Path(sys.argv[0]).name', '"bootstrap-go"') + self.archive = self.root / "go.tar.gz" + with tarfile.open(self.archive, "w:gz") as archive: + data = bootstrap.encode() + entry = tarfile.TarInfo("go/bin/go") + entry.size = len(data) + entry.mode = 0o755 + archive.addfile(entry, io.BytesIO(data)) + self.logPath = self.root / "calls.jsonl" + self.env = dict(os.environ, PATH=f"{self.binDir}:/usr/bin:/bin", + INSTALLED_GO="1.24.0", BUILD_LOG=str(self.logPath), + XDG_CACHE_HOME=str(self.root / "cache"), + ARCHIVE_PATH=str(self.archive), + ARCHIVE_SHA=hashlib.sha256(self.archive.read_bytes()).hexdigest(), + GOTOOLCHAIN="auto", CGO_ENABLED="1", GOFLAGS="-mod=readonly", + CGO_CFLAGS="-O2", GOMODCACHE=str(self.root / "modules")) + + def run_build(self, *args): + return subprocess.run(["/bin/bash", str(self.script), *args], cwd=self.caller, + env=self.env, text=True, capture_output=True, check=False) + + def calls(self, name): + if not self.logPath.exists(): + return [] + return [call for line in self.logPath.read_text().splitlines() + if (call := json.loads(line))[0] == name] + + def test_local_build_paths_flags_and_environment(self): + self.env["GOTOOLCHAIN"] = "local" + result = self.run_build("--output-dir", "output directory", "--", "-trimpath") + self.assertEqual(result.returncode, 0, result.stderr) + for binary in ("cthulhu", "cthulhu-braille"): + self.assertTrue((self.caller / "output directory" / binary).is_file()) + builds = [call for call in self.calls("go") if call[1][0] == "build"] + self.assertEqual(len(builds), 2) + for call in builds: + self.assertIn("-trimpath", call[1]) + self.assertEqual(call[2], str(self.project)) + for name in ("GOTOOLCHAIN", "GOFLAGS", "CGO_CFLAGS", "GOMODCACHE"): + self.assertEqual(call[3][name], self.env[name]) + self.assertIn("brlapi", builds[1][1]) + self.assertFalse(self.calls("curl")) + + def test_old_go_bootstrap_and_cache_reuse(self): + self.env["INSTALLED_GO"] = "1.20.14" + result = self.run_build() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.project / "cthulhu-braille").is_file()) + self.assertEqual(len(self.calls("curl")), 2) + result = self.run_build() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls("curl")), 2) + self.assertEqual(len([c for c in self.calls("bootstrap-go") if c[1][0] == "build"]), 4) + + def test_checksum_failure_never_executes_download(self): + self.env.update(INSTALLED_GO="1.20.14", ARCHIVE_SHA="0" * 64) + result = self.run_build() + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum mismatch", result.stderr) + self.assertFalse(self.calls("bootstrap-go")) + self.assertFalse(list((self.root / "cache").rglob("*.download.*"))) + + def test_bootstrap_without_installed_go(self): + (self.binDir / "go").unlink() + for name in ("python3", "dirname", "awk", "mkdir", "mktemp", "tar", + "gzip", "sha256sum", "mv", "rm"): + toolPath = sys.executable if name == "python3" else shutil.which(name) + (self.binDir / name).symlink_to(toolPath) + self.env["PATH"] = str(self.binDir) + result = self.run_build() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls("curl")), 2) + self.assertTrue((self.project / "cthulhu").is_file()) + + def test_local_old_go_does_not_download(self): + self.env.update(INSTALLED_GO="1.20.14", GOTOOLCHAIN="local") + result = self.run_build() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Install Go 1.24.0", result.stderr) + self.assertFalse(self.calls("curl")) + + def test_preflight_and_arguments_do_not_download(self): + for args in (("--output-dir",), ("--unknown",)): + with self.subTest(args=args): + self.assertNotEqual(self.run_build(*args).returncode, 0) + self.assertEqual(self.run_build("--help").returncode, 0) + self.assertFalse(self.logPath.exists()) + self.env.update(INSTALLED_GO="1.20.14", MISSING_LIBRARY="xres") + result = self.run_build() + self.assertIn("xres", result.stderr) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.calls("curl")) + + def test_braille_failure_is_not_reported_as_success(self): + self.env["FAIL_BRAILLE"] = "1" + result = self.run_build() + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Cthulhu has been built", result.stdout) + + def test_disabled_cgo_and_output_conflicts_fail_before_building(self): + self.env["CGO_ENABLED"] = "0" + result = self.run_build() + self.assertNotEqual(result.returncode, 0) + self.assertIn("CGO_ENABLED=1", result.stderr) + self.env["CGO_ENABLED"] = "1" + (self.caller / "output").write_text("existing file") + self.assertNotEqual(self.run_build("-o", "output").returncode, 0) + (self.project / "cthulhu-braille").mkdir() + self.assertNotEqual(self.run_build().returncode, 0) + self.assertFalse(self.calls("go")) + + +if __name__ == "__main__": + unittest.main()