Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/hugo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +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
test "$(go list -m -f '{{ .Path }}@{{ .Version }}' github.com/pgsty/oink)" = "github.com/pgsty/oink@v1.0.0"
test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}@{{ .Version }}{{ end }}' github.com/pgsty/oink)"
python3 scripts/oink_module.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 On the dispatch path this step no longer asserts anything master controls.

prepare requires test "$GITHUB_REF" = "refs/heads/master" (line 124), so this YAML is always master's, but source_sha is the resolved candidate branch head (line 159) and the build job checks that out (line 204). The five inline assertions this replaces were therefore master-authoritative against the candidate's go.mod; scripts/oink_module.py comes out of the candidate checkout, so the step is now self-attested. The following step already runs candidate versioning.py, so this is not a new execution boundary - what is lost is the one cheap master-side check that would catch accidental drift in a candidate's go.mod before a staging build.

Requested change: keep a version-independent assertion inline alongside the helper call, so master still bounds the graph while the version floats:

test "$(go list -m -f '{{ .Path }}')" = "github.com/apache/hugegraph-doc"
test "$(go list -m all | wc -l)" -eq 2
test -z "$(go list -m -f '{{ with .Replace }}{{ .Path }}{{ end }}' github.com/pgsty/oink)"
python3 scripts/oink_module.py

- name: Build isolated version artifact
env:
OINK_PYTHON: python3
Expand Down
65 changes: 65 additions & 0 deletions scripts/oink_module.py
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 The contributor docs still state the hard pin this PR replaces.

README.md:35 ("The module graph must resolve github.com/pgsty/oink@v1.0.0"), its Chinese mirror README.md:119, and contribution.md:33 ("must contain exactly the pinned github.com/pgsty/oink@v1.0.0 dependency for this site") are prescriptive statements of the verification contract. After this diff the enforced contract is "whatever go.mod pins, verified against go.sum" - locked_version() accepts any release version. The version they name happens to match master today, but the rule they describe is the one being removed here, and they are the instructions a contributor follows before editing.

Requested change: reword those three lines to describe the lock-file contract rather than a literal version, so they do not need editing again on the 1.1 bump.

Separately, for #496 rather than this PR: NOTICE:9 ("This product includes OINK v1.0.0") is still accurate on master but is now the only place the version is written by hand with nothing checking it. An assertion against locked_version()'s return would keep it honest across the bump.

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))
81 changes: 81 additions & 0 deletions scripts/test_oink_module.py
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 5 additions & 23 deletions scripts/versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 GO_BIN is now resolved twice, and the failure message lost the value.

This line keeps guarding line 4135, where go_executable sets the PATH Hugo inherits, so the check must stay. But the old code passed go_executable straight into the go mod download subprocess, which made it the single resolution point; oink_module.command now re-reads os.environ.get("GO_BIN", "go") raw, so the binary that validates the lock files and the binary prepended to Hugo's PATH are resolved independently and can differ. The message on line 4075 also dropped the value - it was fail(f"Go executable is unavailable: {go}") before, which told the operator what GO_BIN had been set to.

Requested change: pass the resolved go_executable into download_locked/command so one path is used end to end, and restore the value in the failure message.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Lock-file mismatches now abort with a traceback instead of a clean fail().

The removed code ended in fail(f"unexpected OINK module metadata: {module!r}"), and fail raises SystemExit(message) (line 396) - one line, no traceback, and it printed the offending metadata. download_locked instead raises bare ValueErrors (scripts/oink_module.py:30,33,48,53,56,59) that carry no values, and command lets subprocess.CalledProcessError escape. main() (line 4513) has no handler, so both surface as tracebacks.

Reproduced by substituting h1:WRONG= for the archive hash in a scratch copy of go.sum:

subprocess.CalledProcessError: Command '['go', 'mod', 'download', '-json',
'github.com/pgsty/oink@v1.0.0']' returned non-zero exit status 1

and the go mod download JSON that would name the mismatch is discarded.

Requested change: put the actual and expected values into each ValueError message, and wrap this call so ValueError and CalledProcessError are reported through fail() like every other build error in this file.

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}")
Expand Down
Loading