From 0a7add9510beef51f9550589c346ff720ffc8cda Mon Sep 17 00:00:00 2001 From: dark Date: Tue, 22 Sep 2026 23:15:28 +0800 Subject: [PATCH 1/2] fix(ci): honor the candidate OINK version - read the OINK requirement from candidate go.mod - retain module integrity and replacement checks - allow staging upgrades before production merges --- .github/workflows/hugo.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index f9c751bcc..806f2d995 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -234,7 +234,8 @@ jobs: go mod verify test "$(go list -m -f '{{ .Path }}')" = "github.com/apache/hugegraph-doc" test "$(go list -m all | wc -l)" -eq 2 - test "$(go list -m -f '{{ .Path }}@{{ .Version }}' github.com/pgsty/oink)" = "github.com/pgsty/oink@v1.0.0" + required_version="$(go mod edit -json | jq -er '[.Require[] | select(.Path == "github.com/pgsty/oink") | .Version] | select(length == 1) | .[0]')" + test "$(go list -m -f '{{ .Path }}@{{ .Version }}' github.com/pgsty/oink)" = "github.com/pgsty/oink@$required_version" test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}@{{ .Version }}{{ end }}' github.com/pgsty/oink)" - name: Build isolated version artifact env: From 4ea62b8bdc7d9f8428efb82b5778c5255f49ab49 Mon Sep 17 00:00:00 2001 From: dark Date: Tue, 22 Sep 2026 23:31:39 +0800 Subject: [PATCH 2/2] fix(build): resolve OINK from candidate lock files - share module identity and integrity validation - remove the migration tool version and checksum literals - accept valid blank lines in go.sum - cover supported pins and dependency boundary failures --- .github/workflows/hugo.yml | 7 +--- scripts/oink_module.py | 65 +++++++++++++++++++++++++++++ scripts/test_oink_module.py | 81 +++++++++++++++++++++++++++++++++++++ scripts/versioning.py | 28 +++---------- 4 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 scripts/oink_module.py create mode 100644 scripts/test_oink_module.py diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index 806f2d995..adfc30014 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -231,12 +231,7 @@ jobs: restore-keys: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ matrix.version.id }}- - name: Verify pinned OINK module run: | - go mod verify - test "$(go list -m -f '{{ .Path }}')" = "github.com/apache/hugegraph-doc" - test "$(go list -m all | wc -l)" -eq 2 - required_version="$(go mod edit -json | jq -er '[.Require[] | select(.Path == "github.com/pgsty/oink") | .Version] | select(length == 1) | .[0]')" - test "$(go list -m -f '{{ .Path }}@{{ .Version }}' github.com/pgsty/oink)" = "github.com/pgsty/oink@$required_version" - test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}@{{ .Version }}{{ end }}' github.com/pgsty/oink)" + python3 scripts/oink_module.py - name: Build isolated version artifact env: OINK_PYTHON: python3 diff --git a/scripts/oink_module.py b/scripts/oink_module.py new file mode 100644 index 000000000..6bca61db4 --- /dev/null +++ b/scripts/oink_module.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0. + +"""Resolve the sole theme dependency from the checked-in Go lock files.""" +import json +import os +import re +import subprocess +from pathlib import Path + +MODULE = "github.com/pgsty/oink" +SITE_MODULE = "github.com/apache/hugegraph-doc" +VERSION = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?") + + +def command(args, root): + return subprocess.check_output([os.environ.get("GO_BIN", "go"), *args], cwd=root, text=True) + + +def locked_version(root): + data = json.loads(command(["mod", "edit", "-json"], root)) + requirements = data.get("Require") or [] + if (data.get("Module", {}).get("Path") != SITE_MODULE + or data.get("Replace") or data.get("Exclude") + or len(requirements) != 1 or requirements[0]["Path"] != MODULE): + raise ValueError("expected the HugeGraph module with only OINK and no replace/exclude directives") + version = requirements[0]["Version"] + if not VERSION.fullmatch(version): + raise ValueError("OINK must be pinned to an explicit release version") + return version + + +def download_locked(root): + root = Path(root) + version = locked_version(root) + sums = {} + for line in (root / "go.sum").read_text().splitlines(): + if not line.strip(): + continue + path, revision, digest = line.split() + sums[(path, revision)] = digest + expected = [sums.get((MODULE, version)), sums.get((MODULE, version + "/go.mod"))] + if not all(expected): + raise ValueError("OINK module and go.mod checksums must already exist in go.sum") + module = json.loads(command(["mod", "download", "-json", MODULE + "@" + version], root)) + if (module.get("Path") != MODULE or module.get("Version") != version + or module.get("Sum") != expected[0] or module.get("GoModSum") != expected[1] + or module.get("Error")): + raise ValueError("downloaded OINK identity or checksum differs from lock files") + resolved = json.loads(command(["list", "-m", "-json", MODULE], root)) + if resolved.get("Replace") or resolved.get("Version") != version or resolved.get("Path") != MODULE: + raise ValueError("resolved OINK module differs from lock files") + graph = command(["list", "-m", "all"], root).splitlines() + if graph != [SITE_MODULE, MODULE + " " + version]: + raise ValueError("module graph must contain only this site and the pinned OINK module") + command(["mod", "verify"], root) + return module + + +if __name__ == "__main__": + print(json.dumps(download_locked(Path(__file__).resolve().parents[1]), indent=2)) diff --git a/scripts/test_oink_module.py b/scripts/test_oink_module.py new file mode 100644 index 000000000..897d4691a --- /dev/null +++ b/scripts/test_oink_module.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0. + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import oink_module + + +class ModuleLockTest(unittest.TestCase): + def config(self, version="v1.0.0", **changes): + return dict(Module={"Path": oink_module.SITE_MODULE}, + Require=[{"Path": oink_module.MODULE, "Version": version}], **changes) + + def download(self, version="v1.0.0", config=None, metadata=None, resolved=None, graph=None, sums=True): + module = dict(Path=oink_module.MODULE, Version=version, Sum="h1:archive", GoModSum="h1:mod") + resolution = dict(Path=oink_module.MODULE, Version=version) + module.update(metadata or {}) + resolution.update(resolved or {}) + replies = [json.dumps(config or self.config(version)), json.dumps(module), + json.dumps(resolution), graph or f"{oink_module.SITE_MODULE}\n{oink_module.MODULE} {version}\n", "all modules verified"] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "go.sum").write_text( + f"\n{oink_module.MODULE} {version} h1:archive\n\n{oink_module.MODULE} {version}/go.mod h1:mod\n" if sums else "") + with patch.object(oink_module, "command", side_effect=replies) as command: + result = oink_module.download_locked(root) + self.assertEqual(command.call_args.args[0], ["mod", "verify"]) + return result + + def test_accepts_each_locked_release(self): + for version in ["v1.0.0", "v1.1.0"]: + with self.subTest(version=version): + self.assertEqual(self.download(version)["Version"], version) + + def test_rejects_changed_dependency_boundary(self): + configurations = [self.config(Replace=[{"New": {"Path": "local"}}]), + self.config(Exclude=[{"Path": oink_module.MODULE, "Version": "v1.0.0"}])] + wrong_site = self.config() + wrong_site["Module"]["Path"] = "example.com/site" + configurations.append(wrong_site) + extra = self.config() + extra["Require"].append({"Path": "example.com/extra", "Version": "v1.0.0"}) + configurations.append(extra) + for config in configurations: + with self.subTest(config=config), self.assertRaises(ValueError): + self.download(config=config) + + def test_missing_checksums_stop_before_download(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "go.sum").write_text("") + with patch.object(oink_module, "command", return_value=json.dumps(self.config())) as command: + with self.assertRaises(ValueError): + oink_module.download_locked(root) + self.assertEqual(command.call_count, 1) + + def test_rejects_download_identity_or_checksum_mismatch(self): + for metadata in [{"Sum": "h1:wrong"}, {"GoModSum": "h1:wrong"}, + {"Path": "example.com/theme"}, {"Version": "v9.0.0"}]: + with self.subTest(metadata=metadata), self.assertRaises(ValueError): + self.download(metadata=metadata) + + def test_rejects_resolved_override_and_extra_dependency(self): + for resolved in [{"Replace": {"Path": "local"}}, {"Version": "v9.0.0"}, + {"Path": "example.com/theme"}]: + with self.subTest(resolved=resolved), self.assertRaises(ValueError): + self.download(resolved=resolved) + with self.assertRaises(ValueError): + self.download(graph=f"{oink_module.SITE_MODULE}\n{oink_module.MODULE} v1.0.0\nexample.com/extra v1.0.0\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/versioning.py b/scripts/versioning.py index 84d9a2d5f..e9b48cc24 100644 --- a/scripts/versioning.py +++ b/scripts/versioning.py @@ -34,6 +34,8 @@ import xml.etree.ElementTree as ET from typing import NoReturn +from oink_module import download_locked + ROOT = pathlib.Path(__file__).resolve().parents[1] URL_CONTRACT = ROOT / "dist/url-contract.json" @@ -4068,30 +4070,10 @@ def build(args: argparse.Namespace) -> None: json.dumps(override, ensure_ascii=False), encoding="utf-8" ) hugo = os.environ.get("HUGO_BIN", "hugo") - go = os.environ.get("GO_BIN", "go") - go_executable = shutil.which(go) + go_executable = shutil.which(os.environ.get("GO_BIN", "go")) if go_executable is None: - fail(f"Go executable is unavailable: {go}") - module_result = subprocess.run( - [ - go_executable, - "mod", - "download", - "-json", - "github.com/pgsty/oink@v1.0.0", - ], - cwd=assembly, - check=True, - stdout=subprocess.PIPE, - text=True, - ) - module = json.loads(module_result.stdout) - if ( - module.get("Path") != "github.com/pgsty/oink" - or module.get("Version") != "v1.0.0" - or module.get("Sum") != "h1:E+WHFP9zSRT+5RKoIkWNp+ASRGS1BKG+rDEi9by/BjE=" - ): - fail(f"unexpected OINK module metadata: {module!r}") + fail("Go executable is unavailable") + module = download_locked(assembly) migration_script = pathlib.Path(module["Dir"]) / "bin/migrations/oink06.py" if not migration_script.is_file(): fail(f"pinned OINK migration tool is absent: {migration_script}")