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
91 changes: 76 additions & 15 deletions continuous_delivery_scripts/plugins/golang.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
"""Plugin for Golang projects."""

import json
import logging
import os
import shutil
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"]:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions continuous_delivery_scripts/utils/git_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions news/20260604124611.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: `[GO]` support go workspaces and project with multiple go project defined
113 changes: 113 additions & 0 deletions tests/plugin/test_golang.py
Original file line number Diff line number Diff line change
@@ -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")])
Loading