diff --git a/continuous_delivery_scripts/plugins/golang.py b/continuous_delivery_scripts/plugins/golang.py index c941675..d169daf 100644 --- a/continuous_delivery_scripts/plugins/golang.py +++ b/continuous_delivery_scripts/plugins/golang.py @@ -4,6 +4,7 @@ # """Plugin for Golang projects.""" +import json import logging import os import shutil @@ -143,24 +144,85 @@ def _call_goreleaser_check(version: str) -> None: check_call(_generate_goreleaser_check_command_list(), cwd=ROOT_DIR, env=env) -def _determine_go_module_tag(version: str) -> Optional[str]: - """Determines go module for tagging. - - See https://golang.org/ref/mod#vcs-version. - and https://github.com/golang/go/wiki/Modules#should-i-have-multiple-modules-in-a-single-repository. - """ - module = "" +def _determine_go_module_tag_for_directory(module_directory: Path, version: str) -> Optional[str]: try: - module = str(SRC_DIR.relative_to(ROOT_DIR)) + module = module_directory.relative_to(ROOT_DIR) except ValueError: try: - module = str(ROOT_DIR.relative_to(SRC_DIR)) + module = ROOT_DIR.relative_to(module_directory) except ValueError as exception: logger.warning(exception) - if module == "." or len(module) == 0: + return None + module_as_posix = module.as_posix().rstrip("/") + if module_as_posix == "." or len(module_as_posix) == 0: return None - module = module.rstrip("/") - return f"{module}/{version}" + return f"{module_as_posix}/{version}" + + +def _find_go_work_files() -> List[Path]: + go_work_files: List[Path] = [] + for go_work_file in [SRC_DIR.joinpath("go.work"), ROOT_DIR.joinpath("go.work")]: + if go_work_file.exists() and go_work_file not in go_work_files: + go_work_files.append(go_work_file) + return go_work_files + + +def _determine_go_work_module_directories_from_json(go_work_file: Path) -> List[Path]: + """Determine module directories from `go work edit -json` output. + + `go.work` lists all workspace modules that should be released together. + See https://go.dev/ref/mod#workspaces and https://pkg.go.dev/cmd/go#hdr-Edit_workspace_file. + """ + go_work_root = go_work_file.parent + go_work = json.loads(check_output(["go", "work", "edit", "-json"], cwd=go_work_root, encoding="utf8")) + module_directories: List[Path] = [] + for use_definition in go_work.get("Use", []): + disk_path = use_definition.get("DiskPath") or use_definition.get("Path") + if not disk_path: + continue + module_directory = Path(str(disk_path)) + module_directories.append( + module_directory if module_directory.is_absolute() else go_work_root.joinpath(module_directory) + ) + return module_directories + + +def _determine_go_work_module_directories() -> List[Path]: + """Determine module directories declared in `go.work`. + + `go.work` lists all workspace modules that should be released together. + See https://go.dev/ref/mod#workspaces. + """ + module_directories: List[Path] = [] + for go_work_file in _find_go_work_files(): + module_directories.extend(_determine_go_work_module_directories_from_json(go_work_file)) + return list(dict.fromkeys(module_directories)) + + +def _determine_go_subproject_directories() -> List[Path]: + if not SRC_DIR.exists(): + return [] + return sorted((go_mod_file.parent for go_mod_file in SRC_DIR.rglob("go.mod")), key=lambda path: str(path)) + + +def _determine_go_module_tag(version: str) -> List[str]: + """Determine all go module tags for release. + + See https://golang.org/ref/mod#vcs-version, + https://go.dev/ref/mod#workspaces, and + https://github.com/golang/go/wiki/Modules/a549b3e4b7ad6be6e7d11c37ef247bb2279c8146#faqs--multi-module-repositories. + """ + module_directories = [SRC_DIR] + go_work_module_directories = _determine_go_work_module_directories() + if go_work_module_directories: + module_directories.extend(go_work_module_directories) + else: + module_directories.extend(_determine_go_subproject_directories()) + + tags = [ + _determine_go_module_tag_for_directory(module_directory, version) for module_directory in module_directories + ] + return list(dict.fromkeys([tag for tag in tags if tag])) class Go(BaseLanguage): @@ -209,7 +271,7 @@ def get_secret_registry_exclude_files(self) -> List[str]: r".*go\.sum$", r"^\.circleci[\\/].*", r"^workflows/.*", - r"^\.github[\\/]workflows[\\/].*", + (r"^\.github[\\/]workflows[\\/].*"), ] def get_current_spdx_project(self) -> Optional["SpdxProject"]: @@ -224,8 +286,7 @@ def should_clean_before_packaging(self) -> bool: def tag_release(self, git: GitWrapper, version: str, shortcuts: Dict[str, bool]) -> None: """Tags release commit.""" super().tag_release(git, version, shortcuts) - go_tag = _determine_go_module_tag(self.get_version_tag(version)) - if go_tag: + for go_tag in _determine_go_module_tag(self.get_version_tag(version)): git.create_tag(go_tag, message=f"Golang module release: {go_tag}") def _call_goreleaser_release(self, version: str) -> None: diff --git a/continuous_delivery_scripts/utils/git_helpers.py b/continuous_delivery_scripts/utils/git_helpers.py index 2472615..30c3fa7 100644 --- a/continuous_delivery_scripts/utils/git_helpers.py +++ b/continuous_delivery_scripts/utils/git_helpers.py @@ -808,6 +808,7 @@ def __exit__(self, type: Any, value: Any, traceback: Any) -> None: Tempfiles objects on Windows are holding references to open files until they are collected by the garbage collector, thus preventing deletion. """ + self._clone.repo.close() self._repo.repo.close() self._temporary_dir.cleanup() diff --git a/news/20260604124611.feature b/news/20260604124611.feature new file mode 100644 index 0000000..622c18b --- /dev/null +++ b/news/20260604124611.feature @@ -0,0 +1 @@ +:sparkles: `[GO]` support go workspaces and project with multiple go project defined diff --git a/tests/plugin/test_golang.py b/tests/plugin/test_golang.py new file mode 100644 index 0000000..343b858 --- /dev/null +++ b/tests/plugin/test_golang.py @@ -0,0 +1,113 @@ +# +# Copyright (C) 2020-2026 Arm Limited or its affiliates and Contributors. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +import shutil +from pathlib import Path +from unittest import TestCase, mock +from unittest import skipUnless + +from continuous_delivery_scripts.plugins import golang +from continuous_delivery_scripts.utils.filesystem_helpers import TemporaryDirectory + +GO_AVAILABLE = shutil.which("go") is not None + + +class TestGoModuleTags(TestCase): + def test_determine_go_module_tag_keeps_current_module(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source_dir = root.joinpath("src") + source_dir.mkdir() + + with mock.patch.object(golang, "ROOT_DIR", root), mock.patch.object(golang, "SRC_DIR", source_dir): + tags = golang._determine_go_module_tag("v1.2.3") + + self.assertEqual(tags, ["src/v1.2.3"]) + + def test_determine_go_module_tag_reads_go_work_modules(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source_dir = root.joinpath("src") + source_dir.mkdir() + root.joinpath("go.work").touch() + + with mock.patch.object(golang, "ROOT_DIR", root), mock.patch.object( + golang, "SRC_DIR", source_dir + ), mock.patch.object( + golang, + "check_output", + return_value='{"Use": [{"DiskPath": "./app1"}, {"DiskPath": "./nested/app2"}]}', + ): + tags = golang._determine_go_module_tag("v1.2.3") + + self.assertEqual(tags, ["src/v1.2.3", "app1/v1.2.3", "nested/app2/v1.2.3"]) + + def test_determine_go_module_tag_reads_go_work_from_source_dir(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source_dir = root.joinpath("src") + source_dir.mkdir() + source_dir.joinpath("go.work").touch() + + with mock.patch.object(golang, "ROOT_DIR", root), mock.patch.object( + golang, "SRC_DIR", source_dir + ), mock.patch.object( + golang, + "check_output", + return_value='{"Use": [{"DiskPath": "./app1"}, {"DiskPath": "./nested/app2"}]}', + ): + tags = golang._determine_go_module_tag("v1.2.3") + + self.assertEqual(tags, ["src/v1.2.3", "src/app1/v1.2.3", "src/nested/app2/v1.2.3"]) + + def test_determine_go_module_tag_reads_multiple_go_work_files(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source_dir = root.joinpath("src") + source_dir.mkdir() + root.joinpath("go.work").touch() + source_dir.joinpath("go.work").touch() + + def check_output_side_effect(command, cwd=None, encoding=None): + if Path(str(cwd)) == source_dir: + return '{"Use": [{"DiskPath": "./app1"}]}' + if Path(str(cwd)) == root: + return '{"Use": [{"DiskPath": "./shared"}, {"DiskPath": "./root-app"}]}' + raise AssertionError(f"Unexpected cwd: {cwd}") + + with mock.patch.object(golang, "ROOT_DIR", root), mock.patch.object( + golang, "SRC_DIR", source_dir + ), mock.patch.object(golang, "check_output", side_effect=check_output_side_effect): + tags = golang._determine_go_module_tag("v1.2.3") + + self.assertEqual(tags, ["src/v1.2.3", "src/app1/v1.2.3", "shared/v1.2.3", "root-app/v1.2.3"]) + + def test_determine_go_module_tag_reads_nested_go_mod_projects(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + source_dir = root.joinpath("src") + source_dir.mkdir() + source_dir.joinpath("go.mod").write_text("module example.com/src\n", encoding="utf8") + source_dir.joinpath("service-a").mkdir() + source_dir.joinpath("service-a", "go.mod").write_text("module example.com/service-a\n", encoding="utf8") + source_dir.joinpath("service-b").mkdir() + source_dir.joinpath("service-b", "go.mod").write_text("module example.com/service-b\n", encoding="utf8") + + with mock.patch.object(golang, "ROOT_DIR", root), mock.patch.object(golang, "SRC_DIR", source_dir): + tags = golang._determine_go_module_tag("v1.2.3") + + self.assertEqual(tags, ["src/v1.2.3", "src/service-a/v1.2.3", "src/service-b/v1.2.3"]) + + +@skipUnless(GO_AVAILABLE, "go command is required for this integration test") +class TestGoWorkIntegration(TestCase): + def test_determine_go_work_module_directories_from_json_with_go(self): + with TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + root.joinpath("go.work").write_text("go 1.22\n\nuse ./app1\n", encoding="utf8") + root.joinpath("app1").mkdir() + + directories = golang._determine_go_work_module_directories_from_json(root.joinpath("go.work")) + + self.assertEqual(directories, [root.joinpath("app1")])