From f888e0cb0618545ca06d5bc04ff57d9a3484de53 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Mon, 19 Oct 2026 14:04:00 +0100 Subject: [PATCH 1/2] :sparkles: Prepare coordinated Go module releases and plugin guides --- README.md | 5 +- continuous_delivery_scripts/plugins/golang.py | 289 ++++++++++- .../tag_and_release.py | 18 +- .../utils/language_specifics_base.py | 4 + guides/automating-releases.md | 4 +- guides/go-multi-module.md | 220 ++++++++ guides/index.md | 8 +- guides/plugin-basic.md | 8 + guides/plugin-ci.md | 7 + guides/plugin-docker.md | 8 + guides/plugin-github-actions.md | 8 + guides/plugin-golang.md | 11 + guides/plugin-guides.md | 17 + guides/plugin-noop.md | 7 + guides/plugin-python.md | 10 + news/20261019.doc | 1 + news/20261019.feature | 1 + .../test_generate_docs_python.py | 10 + tests/plugin/test_go_release_requirements.py | 478 ++++++++++++++++++ tests/plugin/test_golang.py | 17 +- 20 files changed, 1097 insertions(+), 34 deletions(-) create mode 100644 guides/go-multi-module.md create mode 100644 guides/plugin-basic.md create mode 100644 guides/plugin-ci.md create mode 100644 guides/plugin-docker.md create mode 100644 guides/plugin-github-actions.md create mode 100644 guides/plugin-golang.md create mode 100644 guides/plugin-guides.md create mode 100644 guides/plugin-noop.md create mode 100644 guides/plugin-python.md create mode 100644 news/20261019.doc create mode 100644 news/20261019.feature create mode 100644 tests/plugin/test_go_release_requirements.py diff --git a/README.md b/README.md index d7c59b0..4add07d 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ including paths, versioning rules and the selected `PROGRAMMING_LANGUAGE`, regardless of the project's language or CI system. The selected plugin then uses appropriate ecosystem tools: for example, GoReleaser for Go releases or wheel and Twine for Python packages. Consult the -[plugin guides](./continuous_delivery_scripts/plugins) for language-specific -requirements; native build manifests and credentials may still be needed. See +[plugin guides](./guides/plugin-guides.md) for language-specific +requirements, including the [Go plugin guides](./guides/plugin-golang.md); +native build manifests and credentials may still be needed. See the [project configuration guide](./DEVELOPMENT.md#project-configuration) for the `pyproject.toml` fields required by each workflow. diff --git a/continuous_delivery_scripts/plugins/golang.py b/continuous_delivery_scripts/plugins/golang.py index 01494ed..2e5fad2 100644 --- a/continuous_delivery_scripts/plugins/golang.py +++ b/continuous_delivery_scripts/plugins/golang.py @@ -12,9 +12,11 @@ import shutil from contextlib import contextmanager from pathlib import Path -from subprocess import check_call, check_output, run, CalledProcessError +from subprocess import check_call, check_output, run, CalledProcessError, STDOUT from tempfile import TemporaryDirectory -from typing import Optional, List, Dict, MutableMapping, Iterator +from typing import Optional, List, Dict, MutableMapping, Iterator, Set + +import semver from continuous_delivery_scripts.spdx_report.spdx_project import SpdxProject from continuous_delivery_scripts.utils.configuration import ( @@ -41,6 +43,9 @@ SRC_DIR = Path(str(configuration.get_value(ConfigurationVariable.SOURCE_DIR))) ROOT_DIR = Path(str(configuration.get_value(ConfigurationVariable.PROJECT_ROOT))) +GO_MOD_FILENAME = "go.mod" +GO_WORK_FILENAME = "go.work" +GO_ALL_PACKAGES_PATTERN = "./..." ENVVAR_GORELEASER_GIT_TOKEN = "GITHUB_TOKEN" ENVVAR_GORELEASER_CUSTOMISED_TAG = "GORELEASER_CURRENT_TAG" ENVVAR_GO_MOD = "GO111MODULE" @@ -52,6 +57,236 @@ GO_LICENSES_TEMPLATE = "{{range .}}{{.Name}}\t{{.Version}}\t{{.LicenseURL}}\t{{.LicenseName}}\n{{end}}" LICENCE_FILE_PREFIXES = ("LICENSE", "LICENCE", "COPYING") +# JSON fields returned by `go mod edit -json` and `go work edit -json`. +# https://go.dev/ref/mod#go-mod-edit +# https://go.dev/ref/mod#go-work-edit +GO_JSON_MODULE = "Module" +GO_JSON_REQUIRE = "Require" +GO_JSON_REPLACE = "Replace" +GO_JSON_OLD = "Old" +GO_JSON_NEW = "New" +GO_JSON_PATH = "Path" +GO_JSON_VERSION = "Version" +GO_JSON_USE = "Use" +GO_JSON_DISK_PATH = "DiskPath" + + +def _go_edit_command(file: Path, subcommand: str, flags: List[str]) -> List[str]: + """Target an explicit go.mod or go.work, independent of the current directory.""" + return ["go", subcommand, "edit", *flags, str(file)] + + +def _read_go_metadata_with_edit_command(file: Path, subcommand: str) -> dict: + """Use Go's parser to inspect directives without resolving unpublished modules.""" + try: + return json.loads(check_output(_go_edit_command(file, subcommand, ["-json"]), encoding="utf8", stderr=STDOUT)) + except (CalledProcessError, ValueError, OSError) as error: + detail = error.output if isinstance(error, CalledProcessError) else str(error) + raise ValueError(f"Cannot parse {file} with Go tooling: {detail}") from error + + +def _read_go_module_metadata(go_mod_file: Path) -> dict: + """Read module identity and dependencies without modifying go.mod.""" + return _read_go_metadata_with_edit_command(go_mod_file, "mod") + + +def _read_go_workspace_metadata(go_work_file: Path) -> dict: + """Read workspace module selections and replacements without changing go.work.""" + return _read_go_metadata_with_edit_command(go_work_file, "work") + + +def _release_checkout_path(path: Path, root: Path) -> Path: + """Map a path chosen by tag discovery to the same location in the release checkout. + + Never prepare modules outside the configured project root, even when a + workspace happens to reference them. + """ + source_root = ROOT_DIR.resolve() + resolved = path.resolve() + if not resolved.is_relative_to(source_root): + raise ValueError(f"Go module path is outside the project root: {path}") + return root.resolve() / resolved.relative_to(source_root) + + +def _discover_release_modules(root: Path) -> Dict[str, Path]: + """Index only tagging's selected modules by their declared import paths. + + Include the root module when present. Exact declared paths, rather than + repository-relative names or shared prefixes, identify internal dependencies. + """ + modules: Dict[str, Path] = {} + for directory in _candidate_go_module_directories(include_project_root=True): + file = _release_checkout_path(directory, root) / GO_MOD_FILENAME + if not file.is_file(): + continue + metadata = _read_go_module_metadata(file) + name = (metadata.get(GO_JSON_MODULE) or {}).get(GO_JSON_PATH) + if not name: + raise ValueError(f"Missing module declaration in {file}") + if name in modules: + raise ValueError(f"Duplicate Go module declaration {name!r}: {modules[name]} and {file}") + modules[name] = file + return modules + + +def _release_workspaces(root: Path) -> List[Path]: + """Locate the root/source workspaces used by tag discovery for validation.""" + return [_release_checkout_path(path, root) for path in _find_go_work_files()] + + +def _go_major(version: str) -> int: + """Parse the major component of CDS or Go semver, including pseudo-versions.""" + try: + return semver.VersionInfo.parse(version.removeprefix("v")).major + except ValueError as error: + raise ValueError(f"Invalid Go module version: {version!r}") from error + + +def _go_module_path_major(module: str) -> Optional[int]: + """Parse a Go path's /vN (or gopkg.in .vN) semantic import suffix. + + Module paths encode only the major version. Append zero minor and patch + components so the existing semver parser can validate and extract it. + See https://go.dev/ref/mod#module-path, + https://go.dev/doc/modules/major-version and https://semver.org/#spec-item-2. + """ + separator = ".v" if module.startswith("gopkg.in/") else "/v" + _, found, major_suffix = module.rpartition(separator) + if not found: + return None + try: + return _go_major(f"{major_suffix}.0.0") + except ValueError: + return None + + +def _go_module_path_supports_major(module: str, major: int) -> bool: + """Match v2+ suffixes and reject them for unsuffixed v0/v1 releases.""" + path_major = _go_module_path_major(module) + if major >= 2: + return path_major == major + return path_major is None or path_major < 2 + + +def _check_internal_go_version(module: str, old: str, target: str) -> None: + """Reject requirements incompatible with the module path before any edits. + + Warn only when a change of major version also requires a manual path and + import migration; releases within an existing major remain supported. + """ + target_major = _go_major(target) + if _go_module_path_supports_major(module, target_major): + return + + if _go_major(old) != target_major and target_major >= 2: + logger.warning( + "Go major-version migration is only partially supported. CDS does not migrate module " + "declarations, source imports or replace directives. Manual migration is required: " + "https://go.dev/doc/modules/major-version" + ) + raise ValueError( + f"Internal Go module {module!r} cannot require {target}: its module path must match " + f"major version v{target_major}. Migrate module declarations, imports and requirements before release." + ) + + +def _validate_local_go_targets( + metadata: dict, file: Path, modules: Dict[str, Path], root: Path, target_version: str +) -> Set[str]: + """Return internal modules whose replacements will resolve the target version. + + Check local targets inside the project, but leave replacements pointing + outside the repository alone. A version-specific replacement for the old + requirement does not necessarily resolve the upcoming release version. + """ + locally_replaced: Set[str] = set() + for replacement in metadata.get(GO_JSON_REPLACE) or []: + old = replacement[GO_JSON_OLD][GO_JSON_PATH] + new = replacement[GO_JSON_NEW] + if new.get(GO_JSON_VERSION): + continue + target = (file.parent / new[GO_JSON_PATH]).resolve() + if not target.is_relative_to(root.resolve()): + continue + if not (target / GO_MOD_FILENAME).is_file(): + raise ValueError(f"Local replacement for {old} in {file} has no {GO_MOD_FILENAME} at {target}") + if old in modules and target != modules[old].parent.resolve(): + raise ValueError(f"Local replacement for {old} in {file} points to {target}, not {modules[old].parent}") + if old in modules and replacement[GO_JSON_OLD].get(GO_JSON_VERSION) in (None, "", target_version): + locally_replaced.add(old) + return locally_replaced + + +def _plan_internal_go_requirement_updates( + modules: Dict[str, Path], workspace_modules: Dict[Path, Set[str]], root: Path, target_version: str +) -> Dict[Path, List[str]]: + """Plan edits after exact path matching, before touching any go.mod files. + + Update only requirements resolved through a repository-local replacement + or through a workspace containing the requiring and required modules. + """ + changes: Dict[Path, List[str]] = {} + for module_file in modules.values(): + metadata = _read_go_module_metadata(module_file) + locally_resolved = _validate_local_go_targets(metadata, module_file, modules, root, target_version) + locally_resolved.update(workspace_modules.get(module_file, set())) + outdated = [] + for requirement in metadata.get(GO_JSON_REQUIRE) or []: + path = requirement[GO_JSON_PATH] + if path in locally_resolved: + _check_internal_go_version(path, requirement[GO_JSON_VERSION], target_version) + if requirement[GO_JSON_VERSION] != target_version: + outdated.append(path) + if outdated: + changes[module_file] = sorted(outdated) + return changes + + +def _validate_go_release_workspaces(root: Path, modules: Dict[str, Path], target_version: str) -> Dict[Path, Set[str]]: + """Find internal modules locally available to each workspace member.""" + available_by_module: Dict[Path, Set[str]] = {} + for workspace in _release_workspaces(root): + work_metadata = _read_go_workspace_metadata(workspace) + replaced = _validate_local_go_targets(work_metadata, workspace, modules, root, target_version) + used_directories = set() + for use in work_metadata.get(GO_JSON_USE) or []: + directory = (workspace.parent / use[GO_JSON_DISK_PATH]).resolve() + if not (directory / GO_MOD_FILENAME).is_file(): + raise ValueError(f"Go workspace {workspace} references missing module at {directory}") + if directory.is_relative_to(root.resolve()): + used_directories.add(directory) + available = replaced | {name for name, file in modules.items() if file.parent.resolve() in used_directories} + for file in modules.values(): + if file.parent.resolve() in used_directories: + available_by_module.setdefault(file, set()).update(available) + return available_by_module + + +def _update_internal_go_requirements(file: Path, paths: List[str], target_version: str) -> None: + """Apply only planned require changes using Go tooling, without go get or tidy.""" + logger.info("Updating internal Go requirements in %s to %s", file, target_version) + try: + check_call(_go_edit_command(file, "mod", [f"-require={path}@{target_version}" for path in paths])) + except (CalledProcessError, OSError) as error: + raise ValueError(f"Cannot update internal Go requirements in {file}: {error}") from error + + +def _prepare_go_module_requirements(root: Path, target_version: str) -> List[Path]: + """Validate all selected modules, then update their internal requirements. + + Return only changed go.mod paths so CDS can stage them in its release + commit; a single-module project has no internal dependencies to update. + """ + modules = _discover_release_modules(root) + workspace_modules = _validate_go_release_workspaces(root, modules, target_version) + changes = _plan_internal_go_requirement_updates(modules, workspace_modules, root, target_version) + if len(modules) < 2: + return [] + + for file, paths in changes.items(): + _update_internal_go_requirements(file, paths, target_version) + return list(changes) + def _generate_doc2go_command_list(executable: str, output_directory: Path, patterns: List[str]) -> List[str]: return [ @@ -124,10 +359,12 @@ def _candidate_go_module_directories(include_project_root: bool = False) -> List def _go_licence_module_directories() -> List[Path]: """Find actual Go modules for dependency reporting.""" modules = [ - path for path in _candidate_go_module_directories(include_project_root=True) if (path / "go.mod").is_file() + path + for path in _candidate_go_module_directories(include_project_root=True) + if (path / GO_MOD_FILENAME).is_file() ] if not modules: - raise FileNotFoundError(f"No go.mod found in {SRC_DIR}, {ROOT_DIR} or their Go workspace modules.") + raise FileNotFoundError(f"No {GO_MOD_FILENAME} found in {SRC_DIR}, {ROOT_DIR} or their Go workspace modules.") return modules @@ -139,7 +376,7 @@ def _go_module_name(module_dir: Path, env: MutableMapping[str, str]) -> str: module_env = dict(env) module_env[ENVVAR_GO_WORK] = GO_WORK_OFF_VALUE module = json.loads(check_output(["go", "list", "-m", "-json"], cwd=module_dir, env=module_env, encoding="utf8")) - return str(module["Path"]) + return str(module[GO_JSON_PATH]) def _download_go_module_dependencies(module_directories: List[Path], env: MutableMapping[str, str]) -> None: @@ -202,7 +439,7 @@ def _run_go_licenses_report( ) -> str: """Run go-licenses with captured stderr so CDS controls when diagnostics are shown.""" result = run( - [executable, "report", "./...", "--template", str(template_path)], + [executable, "report", GO_ALL_PACKAGES_PATTERN, "--template", str(template_path)], cwd=module_dir, env=env, encoding="utf8", @@ -341,7 +578,7 @@ def _doc2go_target(module: str) -> tuple[Path, List[str]]: dict.fromkeys( directory.resolve() for directory in _candidate_go_module_directories(include_project_root=True) - if (directory / "go.mod").is_file() + if (directory / GO_MOD_FILENAME).is_file() ) ) @@ -350,16 +587,16 @@ def _doc2go_target(module: str) -> tuple[Path, List[str]]: dict.fromkeys( directory.resolve() for directory in _determine_go_work_module_directories() - if (directory / "go.mod").is_file() and directory.resolve().is_relative_to(root) + if (directory / GO_MOD_FILENAME).is_file() and directory.resolve().is_relative_to(root) ) ) if workspace_modules: return root, [ - "./..." if directory == root else f"./{directory.relative_to(root).as_posix()}/..." + GO_ALL_PACKAGES_PATTERN if directory == root else f"./{directory.relative_to(root).as_posix()}/..." for directory in workspace_modules ] - if (root / "go.mod").is_file(): - return root, ["./..."] + if (root / GO_MOD_FILENAME).is_file(): + return root, [GO_ALL_PACKAGES_PATTERN] raise FileNotFoundError(f"No Go module or workspace found at {ROOT_DIR}") matches = [] @@ -368,12 +605,16 @@ def _doc2go_target(module: str) -> tuple[Path, List[str]]: if requested in {directory.name, str(directory), relative, f"./{relative}"}: matches.append(directory) project_module = (root / requested).resolve() - if project_module.is_relative_to(root) and (project_module / "go.mod").is_file() and project_module not in matches: + if ( + project_module.is_relative_to(root) + and (project_module / GO_MOD_FILENAME).is_file() + and project_module not in matches + ): matches.append(project_module) if len(matches) > 1: raise ValueError(f"MODULE_TO_DOCUMENT={requested!r} matches several Go modules; use a project-relative path") if matches: - return matches[0], ["./..."] + return matches[0], [GO_ALL_PACKAGES_PATTERN] # Package directories can be named relative to the project or source root. package_matches = [] @@ -392,8 +633,8 @@ def _doc2go_target(module: str) -> tuple[Path, List[str]]: return package_matches[0] # A source subdirectory of a module can also be selected by its short name. - if requested == source.name and any((parent / "go.mod").is_file() for parent in (source, *source.parents)): - return source, ["./..."] + if requested == source.name and any((parent / GO_MOD_FILENAME).is_file() for parent in (source, *source.parents)): + return source, [GO_ALL_PACKAGES_PATTERN] # Recognise a full module path without requiring the caller to append /.... if "." in requested and not requested.startswith("./"): @@ -401,7 +642,7 @@ def _doc2go_target(module: str) -> tuple[Path, List[str]]: for directory in modules: import_path = _go_module_name(directory, env) if requested in {import_path, f"{import_path}/..."}: - return directory, ["./..."] + return directory, [GO_ALL_PACKAGES_PATTERN] # An explicit package import path or Go package pattern is used as supplied. return source, [requested] @@ -466,7 +707,7 @@ def _determine_go_module_tag_for_directory(module_directory: Path, version: str) 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")]: + for go_work_file in [SRC_DIR.joinpath(GO_WORK_FILENAME), ROOT_DIR.joinpath(GO_WORK_FILENAME)]: 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 @@ -479,10 +720,10 @@ def _determine_go_work_module_directories_from_json(go_work_file: Path) -> List[ 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")) + go_work = _read_go_workspace_metadata(go_work_file) module_directories: List[Path] = [] - for use_definition in go_work.get("Use", []): - disk_path = use_definition.get("DiskPath") or use_definition.get("Path") + for use_definition in go_work.get(GO_JSON_USE, []): + disk_path = use_definition.get(GO_JSON_DISK_PATH) or use_definition.get(GO_JSON_PATH) if not disk_path: continue module_directory = Path(str(disk_path)) @@ -507,7 +748,7 @@ def _determine_go_work_module_directories() -> List[Path]: 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)) + return sorted((go_mod_file.parent for go_mod_file in SRC_DIR.rglob(GO_MOD_FILENAME)), key=lambda path: str(path)) def _determine_go_module_tag(version: str) -> List[str]: @@ -536,6 +777,12 @@ def get_version_tag(self, version: str) -> str: cleansed_version = version.strip().lstrip("v") return f"v{cleansed_version}" + def prepare_release(self, root: Path, version: str, is_new_version: bool) -> List[Path]: + """Synchronise internal requirements only for a new project release.""" + if not is_new_version: + return [] + return _prepare_go_module_requirements(root, self.get_version_tag(version)) + def package_software(self, mode: CommitType, version: str) -> None: """No operation.""" super().package_software(mode, version) diff --git a/continuous_delivery_scripts/tag_and_release.py b/continuous_delivery_scripts/tag_and_release.py index 1e24382..be774dd 100644 --- a/continuous_delivery_scripts/tag_and_release.py +++ b/continuous_delivery_scripts/tag_and_release.py @@ -9,7 +9,7 @@ import logging import sys from pathlib import Path -from typing import TYPE_CHECKING, Optional, Tuple, Dict +from typing import TYPE_CHECKING, Optional, Tuple, Dict, List from continuous_delivery_scripts.generate_docs import generate_documentation from continuous_delivery_scripts.generate_news import version_project @@ -57,6 +57,9 @@ def tag_and_release(mode: CommitType, current_branch: Optional[str] = None) -> N raise ValueError("Undefined version.") if mode == CommitType.DEVELOPMENT: return + files_changed_by_release_preparation = get_language_specifics().prepare_release( + Path(str(configuration.get_value(ConfigurationVariable.PROJECT_ROOT))), version, is_new_version + ) # The documentation folder will be emptied when the documentation is updated _update_documentation() spdx_project: Optional["SpdxProject"] = None @@ -67,7 +70,9 @@ def tag_and_release(mode: CommitType, current_branch: Optional[str] = None) -> N # Add summaries after the documentation output has been cleared and regenerated. spdx_project = _update_licensing_summary() insert_licence_header(0) - _update_repository(mode, is_new_version, version, current_branch, version_elements) + _update_repository( + mode, is_new_version, version, current_branch, version_elements, files_changed_by_release_preparation + ) if is_new_version: if get_language_specifics().should_clean_before_packaging(): _clean_repository() @@ -118,6 +123,7 @@ def _update_repository( version: str, current_branch: Optional[str], version_elements: Dict[str, str], + files_changed_by_release_preparation: List[Path], ) -> None: """Update repository with changes that happened.""" with ProjectTempClone(desired_branch_name=current_branch) as git: @@ -125,7 +131,7 @@ def _update_repository( time_str = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M") commit_message = f"๐Ÿš€ releasing version {version} @ {time_str}" if is_new_version else "๐Ÿ“ฐ Automatic changes โš™" if mode == CommitType.RELEASE: - _commit_release_changes(git, version, commit_message) + _commit_release_changes(git, version, commit_message, files_changed_by_release_preparation) if is_new_version: get_language_specifics().tag_release( git, @@ -165,8 +171,12 @@ def _add_version_changes(git: GitWrapper) -> None: git.add(configuration.get_value(ConfigurationVariable.NEWS_DIR)) -def _commit_release_changes(git: GitWrapper, version: str, commit_message: str) -> None: +def _commit_release_changes( + git: GitWrapper, version: str, commit_message: str, files_changed_by_release_preparation: List[Path] +) -> None: logger.info(f"Committing release [{version}]...") + for path in files_changed_by_release_preparation: + git.add(path) git.add(configuration.get_value(ConfigurationVariable.DOCUMENTATION_PRODUCTION_OUTPUT_PATH)) _add_version_changes(git) _commit_changes(commit_message, git) diff --git a/continuous_delivery_scripts/utils/language_specifics_base.py b/continuous_delivery_scripts/utils/language_specifics_base.py index 4a79b37..3396229 100644 --- a/continuous_delivery_scripts/utils/language_specifics_base.py +++ b/continuous_delivery_scripts/utils/language_specifics_base.py @@ -87,6 +87,10 @@ def should_clean_before_packaging(self) -> bool: """States whether the repository must be cleaned before packaging happens.""" return False + def prepare_release(self, root: Path, version: str, is_new_version: bool) -> List[Path]: + """Prepare language-specific files for a new release; return paths to stage.""" + return [] + def tag_release(self, git: GitWrapper, version: str, shortcuts: Dict[str, bool]) -> None: """Tags release commit.""" logger.info(f"Tagging commit as release {version}") diff --git a/guides/automating-releases.md b/guides/automating-releases.md index f7c16af..5e985b6 100644 --- a/guides/automating-releases.md +++ b/guides/automating-releases.md @@ -15,7 +15,7 @@ news fragments used to calculate release versions. Run from a configured Git checkout with installed dependencies, a writable remote and the credentials required by the selected language plugin. The -[plugin documentation](https://github.com/ARMmbed/continuous-delivery-scripts/tree/main/continuous_delivery_scripts/plugins) +[plugin guide index](plugin-guides.md) describes packaging, publication and credential requirements for each language. Use a release type of `development`, `beta` or `release`: @@ -33,6 +33,8 @@ does not publish a production release. A production release updates release notes, documentation and tags, and the plugin publishes its package or other configured output. The exact output varies by plugin; see the [release workflow](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/.github/workflows/release.yml). +For Go repositories with several modules, see the +[Go plugin guides](plugin-golang.md). ## GitHub Actions example diff --git a/guides/go-multi-module.md b/guides/go-multi-module.md new file mode 100644 index 0000000..ae1a576 --- /dev/null +++ b/guides/go-multi-module.md @@ -0,0 +1,220 @@ +# Go multi-module projects with CDS + +[Go plugin guides](plugin-golang.md) ยท [All plugin guides](plugin-guides.md) + +## Overview + +Continuous Delivery Scripts (CDS) releases a **project**, not individual modules. +One repository can contain several independently consumable Go modules; they all +share the version calculated by CDS and are released together. The model follows +[go-openapi/swag](https://github.com/go-openapi/swag): versioned internal +requirements describe published dependencies, while local replacements support +development before those versions exist. See also the +[go-openapi CI workflows](https://github.com/go-openapi/ci-workflows). + +## Split a monolithic project + +For a project currently using `module github.com/example/my-project`, extract +packages into their own module directories and give each its own `go.mod`: + +```text +my-project/ +โ”œโ”€โ”€ go.mod # optional root compatibility module +โ”œโ”€โ”€ go.work # optional workspace +โ”œโ”€โ”€ core/go.mod +โ”œโ”€โ”€ parser/go.mod +โ””โ”€โ”€ validator/go.mod +``` + +For example, run `go mod init github.com/example/my-project/core` from `core/`. +Repeat in each directory, using its actual published module path. A root module +can remain as a compatibility layer if it still provides the old packages; it +is not required. Update imports of moved packages yourself, or maintain +compatibility packages at the old paths. CDS does not rewrite source imports. +Check the [Go module reference](https://go.dev/ref/mod) and +[go.mod reference](https://go.dev/doc/modules/gomod-ref) when designing paths. + +At the existing release `v1.2.0`, `core/go.mod` could contain: + +```go +module github.com/example/my-project/core + +go 1.24 +``` + +`parser/go.mod` declares the published dependency and a local development path: + +```go +module github.com/example/my-project/parser + +go 1.24 + +require github.com/example/my-project/core v1.2.0 + +replace github.com/example/my-project/core => ../core +``` + +Go uses the local source from `../core` during module-mode development; the +`require` version describes what an external consumer will resolve. Replacements +in a dependency's `go.mod` are **not** inherited by that consumer. CDS preserves +both grouped and individual `replace` directives, and never adds or removes them. +For independent module-mode builds, provide local replacements for unpublished +internal versions. Ensure their directories exist and contain the expected +module's `go.mod`. + +Alternatively, create `go.work` at the repository root: + +```go +go 1.24 + +use ( + . + ./core + ./parser + ./validator +) +``` + +Omit `.` if there is no root module. An active workspace resolves its listed +modules locally, even without module-level replacements. Workspace-level +replacements take precedence over conflicting module-level replacements. CDS +reads and checks local replacements in `go.work` but does not modify it. +See the [Go workspace tutorial](https://go.dev/doc/tutorial/workspaces). +To test a module without workspace resolution, run `GOWORK=off go test ./...` +from that module's directory; module-level replacements remain active. + +## Coordinated release: v1.2.0 โ†’ v1.3.0 + +Suppose `parser` depends on `core`, and `validator` depends on both. Before +release preparation, `validator/go.mod` contains: + +```go +module github.com/example/my-project/validator + +go 1.24 + +require ( + github.com/example/my-project/core v1.2.0 + github.com/example/my-project/parser v1.2.0 // indirect + github.com/external/library v1.4.0 +) + +replace ( + github.com/example/my-project/core => ../core + github.com/example/my-project/parser => ../parser +) +``` + +CDS calculates the next **project** version `1.3.0` using its existing release +process. + +### Module selection and eligibility + +For a new version, the Go plugin uses the same module selection as +Go release tagging: the configured `SOURCE_DIR` and project root, plus modules +listed in `go.work` if present; otherwise, nested modules under `SOURCE_DIR`. +Set `SOURCE_DIR = "."` for the layout above, or list modules outside the source +directory in a root or source `go.work`. CDS does not scan other project-root +subdirectories for modules. It reads each selected `module` declaration using +Go tooling and matches `require` paths **exactly** against those declarations. +Modules need not be named after their directories or share a path prefix. A +duplicate declaration or malformed file among selected modules stops +preparation with an error. + +CDS updates an internal requirement only when the requiring module has a +`replace` pointing to that exact module **inside this repository**, or the +requiring module is listed in `go.work use` and the dependency is either also +listed there or mapped to a repository-local module by a workspace `replace`. +A version-specific replacement must cover the upcoming version. Otherwise the +requirement stays unchanged, even if its module path is internal. Add local +resolution and rerun preparation before publication if that requirement must +reference the new release version. + +### Result + +After preparation, `parser/go.mod` requires `core v1.3.0`; the two internal +requirements in `validator/go.mod` become: + +```go +require ( + github.com/example/my-project/core v1.3.0 + github.com/example/my-project/parser v1.3.0 // indirect + github.com/external/library v1.4.0 +) +``` + +The local replacements, external dependency, annotations, module and Go +directives remain intact. Files without eligible outdated requirements are not +rewritten. CDS stages only changed `go.mod` files with its normal release +changes. The existing tagging, changelog, versioning and publication steps +continue without module-specific release scheduling. The project release tag +is `v1.3.0`; subdirectory modules can also receive path-prefixed tags such as +`core/v1.3.0`, `parser/v1.3.0` and `validator/v1.3.0`, all on the same commit. + +Go's `go mod edit` changes `require` versions without downloading the new +module versions. Local replacements or the active workspace allow testing +before those versions are published. CDS does not run `go get` or `go mod tidy` +and does not fabricate `go.sum` entries. If release validation runs outside +workspace mode, ensure each unpublished dependency has a local replacement. + +## Validate development and publication + +From each relevant module, run `go test ./...` with the workspace or valid +local replacements enabled. Inspect requirements and replacements with +[`go mod edit -json`](https://go.dev/ref/mod#go-mod-edit); from the workspace +directory, inspect its configuration with +[`go work edit -json`](https://go.dev/ref/mod#go-work-edit). + +After publication, test actual published resolution in a **separate** consumer +directory, outside the original workspace and without local replacements: + +```bash +mkdir release-check +cd release-check +go mod init example.com/release-check +go get github.com/example/my-project/validator@v1.3.0 +go mod verify +go list -m all +go test ./... +``` + +`go test ./...` is meaningful once the consumer contains a Go package that +imports the released module. This check catches issues hidden by local source +resolution. + +## Major-version migration + +Go generally requires a matching `/v2` (or higher) suffix on module paths +and imports for major version 2 and above. For example: + +```go +module github.com/example/my-project/core/v2 +``` + +CDS updates eligible, compatible internal dependencies, such as `v2.1.0` โ†’ +`v2.2.0` for `core/v2`. A transition from an unsuffixed `core` at `v1` to `v2.0.0` +requires manual migration: change module declarations, source imports, +internal requirement paths and relevant replacements before preparing the +release. For an eligible internal requirement, CDS warns when it detects a +major-version transition requiring this migration and refuses to write an +incompatible version to `go.mod`. It does not migrate source code or release +modules independently. See +[Developing a major version update](https://go.dev/doc/modules/major-version). + +## Troubleshooting + +- **A future version cannot be fetched:** Use a valid local `replace` or a + workspace listing the sibling module during preparation and testing. +- **A `go.mod` was not changed:** Check that CDS selects both module directories, + that the `require` path matches the sibling's declaration exactly, and that + its replacement or workspace resolution stays inside the repository. +- **A local replacement fails:** Confirm the target directory contains the + correct module's `go.mod`. Workspace `use` entries also need existing + module directories. +- **A consumer fails after publishing:** Local replacements do not propagate + to consumers. Test with the isolated consumer above and inspect the released + requirements and module tags. +- **A major-version release fails:** Update semantic import paths and imports + manually before running CDS again. + +Additional reading: [Go module version numbering](https://go.dev/doc/modules/version-numbers). diff --git a/guides/index.md b/guides/index.md index b307562..ed31fb2 100644 --- a/guides/index.md +++ b/guides/index.md @@ -7,8 +7,8 @@ the ecosystem tools used for project-specific steps. The tools run in a Git checkout with a Python environment; they are not tied to one CI provider. The [API reference](https://armmbed.github.io/continuous-delivery-scripts/api.html) covers the implementation, while these pages focus on inputs, outputs and examples. -For language-specific packaging, credentials and metadata support, consult -the [plugin guides](https://github.com/ARMmbed/continuous-delivery-scripts/tree/main/continuous_delivery_scripts/plugins). +For language-specific packaging, credentials and metadata support, start with +the [plugin guide index](plugin-guides.md). ## Dependency licences and OpenChain workflows @@ -28,6 +28,10 @@ the [plugin guides](https://github.com/ARMmbed/continuous-delivery-scripts/tree/ - [Generate a changelog](managing-changelogs.md): Build release notes from news fragments. - [Automate releases](automating-releases.md): Tag and publish with a language plugin. +## Plugin guides + +- [Browse plugin guides](plugin-guides.md): Select the landing page for your plugin and its topic guides. + ## Other project checks - [Generate code documentation](generating-code-documentation.md): Build the API reference. diff --git a/guides/plugin-basic.md b/guides/plugin-basic.md new file mode 100644 index 0000000..a5b6b6f --- /dev/null +++ b/guides/plugin-basic.md @@ -0,0 +1,8 @@ +# Basic plugin guides + +Select `PROGRAMMING_LANGUAGE = "basic"` for a project that needs CDS versioning, +changelog commits and Git tags without language-specific packaging. + +- [Automate releases](automating-releases.md) โ€” The shared release workflow. +- [Preview a version](previewing-versions.md) โ€” Inspect the proposed project version. +- [Basic plugin reference](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/continuous_delivery_scripts/plugins/BASIC.MD) โ€” Plugin behaviour. diff --git a/guides/plugin-ci.md b/guides/plugin-ci.md new file mode 100644 index 0000000..fe32e70 --- /dev/null +++ b/guides/plugin-ci.md @@ -0,0 +1,7 @@ +# CI plugin guides + +Select `PROGRAMMING_LANGUAGE = "ci"` for CI configuration projects. The plugin +uses the shared Git release steps and does not package software. + +- [Automate releases](automating-releases.md) โ€” The shared release workflow. +- [CI plugin reference](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/continuous_delivery_scripts/plugins/CI.MD) โ€” Plugin behaviour. diff --git a/guides/plugin-docker.md b/guides/plugin-docker.md new file mode 100644 index 0000000..be062d0 --- /dev/null +++ b/guides/plugin-docker.md @@ -0,0 +1,8 @@ +# Docker plugin guides + +Select `PROGRAMMING_LANGUAGE = "docker"` for a Docker project. The current +plugin supports CDS release orchestration; Docker image build and publication +are not implemented by this plugin. + +- [Automate releases](automating-releases.md) โ€” The shared release workflow. +- [Docker plugin API](https://armmbed.github.io/continuous-delivery-scripts/plugins/docker.html) โ€” Current plugin capabilities. diff --git a/guides/plugin-github-actions.md b/guides/plugin-github-actions.md new file mode 100644 index 0000000..fecff13 --- /dev/null +++ b/guides/plugin-github-actions.md @@ -0,0 +1,8 @@ +# GitHub Actions plugin guides + +Select `PROGRAMMING_LANGUAGE = "github_actions"` for GitHub Actions workflow +projects. This plugin extends the CI release steps with GitHub release actions +and requires the GitHub CLI. + +- [Automate releases](automating-releases.md) โ€” The shared release workflow. +- [GitHub Actions plugin reference](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/continuous_delivery_scripts/plugins/GITHUB_ACTIONS.MD) โ€” Prerequisites and behaviour. diff --git a/guides/plugin-golang.md b/guides/plugin-golang.md new file mode 100644 index 0000000..85a81e8 --- /dev/null +++ b/guides/plugin-golang.md @@ -0,0 +1,11 @@ +# Go plugin guides + +Select `PROGRAMMING_LANGUAGE = "golang"` for a Go project. The plugin uses +GoReleaser for packaging and supports Go module discovery, documentation and +dependency licence reporting. + +- [Go multi-module projects](go-multi-module.md) โ€” Split a project into modules and prepare coordinated internal dependency versions for one CDS release. +- [Automate releases](automating-releases.md) โ€” The shared release workflow. +- [Generate code documentation](generating-code-documentation.md) โ€” Generate Go API pages with doc2go. +- [Generate an SPDX SBOM](generating-an-spdx-sbom.md) โ€” Report Go dependency metadata. +- [Go plugin reference](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/continuous_delivery_scripts/plugins/GOLAND.MD) โ€” Go-specific prerequisites and configuration. diff --git a/guides/plugin-guides.md b/guides/plugin-guides.md new file mode 100644 index 0000000..12fbe86 --- /dev/null +++ b/guides/plugin-guides.md @@ -0,0 +1,17 @@ +# Language and project plugin guides + +CDS uses the project's `PROGRAMMING_LANGUAGE` setting to select a plugin. +Start with the landing page for your plugin, then follow its topic guides: + +- [Basic](plugin-basic.md) โ€” Git-based releases without a language-specific package. +- [CI](plugin-ci.md) โ€” Releases for CI configuration projects. +- [Docker](plugin-docker.md) โ€” Docker project integration. +- [GitHub Actions](plugin-github-actions.md) โ€” CI workflow releases with GitHub integration. +- [Go](plugin-golang.md) โ€” Go releases, modules and dependency preparation. +- [No-op](plugin-noop.md) โ€” Projects that do not need language-specific steps. +- [Python](plugin-python.md) โ€” Python package releases and dependency metadata. + +The [general release guide](automating-releases.md) describes the shared CDS +workflow. Add future plugin-specific topic guides as Markdown files under +`guides/` and link them from the appropriate plugin landing page; the +documentation generator publishes these pages alongside the API reference. diff --git a/guides/plugin-noop.md b/guides/plugin-noop.md new file mode 100644 index 0000000..2096ad1 --- /dev/null +++ b/guides/plugin-noop.md @@ -0,0 +1,7 @@ +# No-op plugin guides + +Select `PROGRAMMING_LANGUAGE = "noop"` when language-specific release or +documentation actions are not needed. The shared CDS commands remain available. + +- [Preview a version](previewing-versions.md) โ€” Inspect the proposed project version. +- [No-op plugin reference](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/continuous_delivery_scripts/plugins/NOOP.MD) โ€” Plugin behaviour. diff --git a/guides/plugin-python.md b/guides/plugin-python.md new file mode 100644 index 0000000..35aa9ff --- /dev/null +++ b/guides/plugin-python.md @@ -0,0 +1,10 @@ +# Python plugin guides + +Select `PROGRAMMING_LANGUAGE = "python"` for Python projects. The plugin uses +wheel and Twine for package releases, pdoc for API documentation and installed +distribution metadata for dependency licence reporting. + +- [Automate releases](automating-releases.md) โ€” The shared release workflow. +- [Generate code documentation](generating-code-documentation.md) โ€” Produce Python API pages. +- [Assess dependency licences](assessing-dependency-licences.md) โ€” Review installed dependency metadata. +- [Python plugin reference](https://github.com/ARMmbed/continuous-delivery-scripts/blob/main/continuous_delivery_scripts/plugins/PYTHON.MD) โ€” Packaging credentials and audit details. diff --git a/news/20261019.doc b/news/20261019.doc new file mode 100644 index 0000000..89533a5 --- /dev/null +++ b/news/20261019.doc @@ -0,0 +1 @@ +:books: Add landing pages for CDS plugins and an index linking their task guides. diff --git a/news/20261019.feature b/news/20261019.feature new file mode 100644 index 0000000..458080f --- /dev/null +++ b/news/20261019.feature @@ -0,0 +1 @@ +:sparkles: [GO plugin] Update locally resolved internal module requirements to the coordinated project release version before publication. diff --git a/tests/generate_docs/test_generate_docs_python.py b/tests/generate_docs/test_generate_docs_python.py index 840dcba..0eae546 100644 --- a/tests/generate_docs/test_generate_docs_python.py +++ b/tests/generate_docs/test_generate_docs_python.py @@ -80,6 +80,16 @@ def generate_api(output_directory, _module): output = pathlib.Path(temporary_root, "site") generate_documentation(output, "continuous_delivery_scripts") + plugin_index = (output / "guides" / "plugin-guides.html").read_text(encoding="utf8") + for plugin in ("basic", "ci", "docker", "github-actions", "golang", "noop", "python"): + with self.subTest(plugin=plugin): + self.assertIn(f'href="plugin-{plugin}.html"', plugin_index) + self.assertIn( + 'href="go-multi-module.html"', + (output / "guides" / "plugin-golang.html").read_text(encoding="utf8"), + ) + self.assertIn('href="plugin-guides.html"', (output / "guides" / "index.html").read_text(encoding="utf8")) + for page in output.rglob("*.html"): html = page.read_text(encoding="utf8") for href in re.findall(r'href="([^"]+)"', html): diff --git a/tests/plugin/test_go_release_requirements.py b/tests/plugin/test_go_release_requirements.py new file mode 100644 index 0000000..d041703 --- /dev/null +++ b/tests/plugin/test_go_release_requirements.py @@ -0,0 +1,478 @@ +# +# Copyright (C) 2026 Arm Limited or its affiliates and Contributors. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +"""Integration tests for project-level Go module release preparation (offline).""" + +import shutil +from pathlib import Path +from unittest import TestCase, mock, skipUnless + +from continuous_delivery_scripts.plugins import golang +from continuous_delivery_scripts.tag_and_release import _commit_release_changes, tag_and_release +from continuous_delivery_scripts.utils.definitions import CommitType +from continuous_delivery_scripts.utils.filesystem_helpers import TemporaryDirectory + + +@skipUnless(shutil.which("go"), "Go is required for module editing") +class TestGoReleaseRequirements(TestCase): + def setUp(self): + self.temp = TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.path) + + def module(self, directory, name, directives=""): + path = self.root / directory / "go.mod" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"module {name}\n\ngo 1.22\n{directives}", encoding="utf8") + return path + + def prepare(self, version="1.3.0", is_new_version=True, source=None): + with mock.patch.object(golang, "ROOT_DIR", self.root), mock.patch.object( + golang, "SRC_DIR", source or self.root + ): + return golang.Go().prepare_release(self.root, version, is_new_version) + + def test_unchanged_release_does_not_inspect_modules(self): + self.module(".", "example.com/project") + with mock.patch.object(golang, "_candidate_go_module_directories") as candidates: + self.assertEqual(self.prepare(is_new_version=False), []) + candidates.assert_not_called() + + def test_cds_version_gains_go_prefix_before_editing_requirements(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../core\n", + ) + self.assertEqual(golang._go_major("1.3.0"), 1) + with mock.patch.object(golang, "check_call", wraps=golang.check_call) as edit: + self.assertEqual(self.prepare("1.3.0"), [consumer]) + edit.assert_called_once_with(["go", "mod", "edit", "-require=example.com/core@v1.3.0", str(consumer)]) + self.assertEqual(self.prepare("v1.3.0"), []) + self.assertIn("example.com/core v1.3.0", consumer.read_text(encoding="utf8")) + + def test_go_major_parses_semantic_and_pseudo_versions(self): + self.assertEqual(golang._go_major("1.3.0-beta.1"), 1) + self.assertEqual(golang._go_major("v2.2.0+incompatible"), 2) + self.assertEqual(golang._go_major("v0.0.0-20220101123456-abcdef123456"), 0) + with self.assertRaisesRegex(ValueError, "Invalid Go module version"): + golang._go_major("v2.invalid") + + def test_source_selection_does_not_scan_unrelated_root_modules(self): + self.module("src/core", "example.com/core") + unrelated = self.module("elsewhere/consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + invalid = self.root / "elsewhere" / "broken" / "go.mod" + invalid.parent.mkdir() + invalid.write_text("not a module\n", encoding="utf8") + original = unrelated.read_bytes() + self.assertEqual(self.prepare(source=self.root / "src"), []) + self.assertEqual(unrelated.read_bytes(), original) + + def test_workspace_selects_modules_outside_source_and_not_unlisted_modules(self): + source = self.root / "src" + source.mkdir() + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + unlisted = self.module("src/unlisted", "example.com/unlisted", "\nrequire example.com/core v1.2.0\n") + self.root.joinpath("go.work").write_text("go 1.22\nuse (\n ./core\n ./consumer\n)\n", encoding="utf8") + original = unlisted.read_bytes() + self.assertEqual(self.prepare(source=source), [consumer]) + self.assertEqual(unlisted.read_bytes(), original) + + def test_single_module_and_no_internal_dependencies_are_untouched(self): + root = self.module(".", "example.com/project", "\nrequire example.com/external/v2 v2.4.0\n") + original = root.read_bytes() + self.assertEqual(self.prepare(), []) + self.assertEqual(root.read_bytes(), original) + other = self.module("nested/core", "another.org/core", "\nrequire example.com/project/core v1.2.0\n") + original_other = other.read_bytes() + self.assertEqual(self.prepare(), []) + self.assertEqual(root.read_bytes(), original) + self.assertEqual(other.read_bytes(), original_other) + + def test_internal_requirement_without_local_resolution_remains_unchanged(self): + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + original = consumer.read_bytes() + self.assertEqual(self.prepare(), []) + self.assertEqual(consumer.read_bytes(), original) + + def test_only_replaced_internal_requirement_is_updated(self): + self.module("core", "example.com/core") + self.module("parser", "example.com/parser") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire (\n example.com/core v1.2.0\n example.com/parser v1.2.0\n)\n" + "replace example.com/core => ../core\n", + ) + self.assertEqual(self.prepare(), [consumer]) + content = consumer.read_text(encoding="utf8") + self.assertIn("example.com/core v1.3.0", content) + self.assertIn("example.com/parser v1.2.0", content) + + def test_outside_repository_replacement_does_not_qualify(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../../external/core\n", + ) + original = consumer.read_bytes() + self.assertEqual(self.prepare(), []) + self.assertEqual(consumer.read_bytes(), original) + + def test_version_specific_replacement_must_cover_new_version(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core v1.2.0 => ../core\n", + ) + self.assertEqual(self.prepare(), []) + self.assertIn("example.com/core v1.2.0", consumer.read_text(encoding="utf8")) + consumer.write_text( + consumer.read_text(encoding="utf8").replace( + "replace example.com/core v1.2.0", "replace example.com/core v1.3.0" + ), + encoding="utf8", + ) + self.assertEqual(self.prepare(), [consumer]) + self.assertIn("require example.com/core v1.3.0", consumer.read_text(encoding="utf8")) + + def test_root_and_nested_modules_direct_indirect_replacements_and_idempotence(self): + root = self.module(".", "example.com/project") + core = self.module("core", "example.com/project/core") + parser = self.module( + "parser", + "example.com/project/parser", + "\nrequire example.com/project/core v1.2.0\nreplace example.com/project/core => ../core\n", + ) + validator = self.module( + "validator", + "example.com/project/validator", + "\n// keep this comment\nrequire (\n example.com/project/core v1.2.0 // indirect\n" + " example.com/project/parser v1.3.0\n example.com/third-party/v2 v2.4.0\n)\n" + "replace (\n example.com/project/core => ../core\n" + " example.com/project/parser => ../parser\n)\n\ntoolchain go1.22.1\n", + ) + untouched = {file: file.read_bytes() for file in (root, core)} + self.assertEqual(self.prepare(), [parser, validator]) + self.assertIn("example.com/project/core v1.3.0", parser.read_text(encoding="utf8")) + content = validator.read_text(encoding="utf8") + self.assertIn("example.com/project/core v1.3.0 // indirect", content) + for fragment in ( + "example.com/project/parser v1.3.0", + "example.com/third-party/v2 v2.4.0", + "// keep this comment", + "toolchain go1.22.1", + "=> ../core", + "=> ../parser", + ): + self.assertIn(fragment, content) + updated = {file: file.read_bytes() for file in (parser, validator)} + self.assertEqual(self.prepare(), []) + self.assertEqual(updated, {file: file.read_bytes() for file in updated}) + self.assertEqual(untouched, {file: file.read_bytes() for file in untouched}) + + def test_no_root_module_with_workspace_and_future_version(self): + core = self.module("core", "example.com/core") + consumer = self.module("nested/consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + workspace = self.root / "go.work" + workspace.write_text( + "go 1.22\nuse (\n ./core\n ./nested/consumer\n)\n" "replace example.com/core => ./core\n", encoding="utf8" + ) + work_original = workspace.read_bytes() + self.assertEqual(self.prepare(), [consumer]) + self.assertIn("example.com/core v1.3.0", consumer.read_text(encoding="utf8")) + self.assertEqual(workspace.read_bytes(), work_original) + self.assertEqual(core.read_text(encoding="utf8"), "module example.com/core\n\ngo 1.22\n") + + def test_no_root_module_and_no_workspace_with_grouped_requirements(self): + self.module("components/engine", "example.com/engine") + consumer = self.module( + "components/cli", + "example.com/cli", + "\nrequire (\n example.com/engine v1.2.0\n example.com/outside v1.0.0\n)\n" + "replace example.com/engine => ../engine\n", + ) + self.assertEqual(self.prepare(), [consumer]) + self.assertIn("example.com/engine v1.3.0", consumer.read_text(encoding="utf8")) + + def test_grouped_workspace_replacements_are_preserved(self): + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + workspace = self.root / "go.work" + workspace.write_text( + "go 1.22\nuse (\n ./core\n ./consumer\n)\n" "replace (\n example.com/core => ./core\n)\n", + encoding="utf8", + ) + original = workspace.read_bytes() + self.assertEqual(self.prepare(), [consumer]) + self.assertEqual(workspace.read_bytes(), original) + + def test_workspace_replacement_qualifies_member_without_dependency_in_use(self): + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + self.root.joinpath("go.work").write_text( + "go 1.22\nuse ./consumer\nreplace example.com/core => ./core\n", encoding="utf8" + ) + self.assertEqual(self.prepare(source=self.root / "core"), [consumer]) + + def test_workspace_use_requires_consumer_to_be_a_member(self): + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + self.root.joinpath("go.work").write_text("go 1.22\nuse ./core\n", encoding="utf8") + self.assertEqual(self.prepare(source=self.root / "consumer"), []) + self.assertIn("example.com/core v1.2.0", consumer.read_text(encoding="utf8")) + + def test_nested_workspace_resolves_paths_relative_to_workspace(self): + self.module("src/core", "example.com/core") + consumer = self.module("src/consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + workspace = self.root / "src" / "go.work" + workspace.write_text("go 1.22\nuse (\n ./core\n ./consumer\n)\n", encoding="utf8") + self.assertEqual(self.prepare(source=self.root / "src"), [consumer]) + + def test_missing_or_wrong_local_replace_target_fails_without_changes(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../missing\n", + ) + original = consumer.read_bytes() + with self.assertRaisesRegex(ValueError, "Local replacement.*no go.mod"): + self.prepare() + self.assertEqual(consumer.read_bytes(), original) + self.module("missing", "example.com/not-core") + with self.assertRaisesRegex(ValueError, "Local replacement.*points to"): + self.prepare() + + def test_missing_workspace_use_and_replace_target(self): + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + workspace = self.root / "go.work" + workspace.write_text("go 1.22\nuse ./missing\n", encoding="utf8") + with self.assertRaisesRegex(ValueError, "references missing module"): + self.prepare() + self.assertIn("v1.2.0", consumer.read_text(encoding="utf8")) + workspace.write_text("go 1.22\nuse ./core\nreplace example.com/core => ./missing\n", encoding="utf8") + with self.assertRaisesRegex(ValueError, "Local replacement.*no go.mod"): + self.prepare() + + def test_missing_external_local_replacement_target(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/outside => ../missing\n", + ) + with self.assertRaisesRegex(ValueError, "Local replacement.*no go.mod"): + self.prepare() + self.assertIn("example.com/core v1.2.0", consumer.read_text(encoding="utf8")) + + def test_duplicate_and_invalid_selected_modules_ignore_outside_source(self): + self.module("src/core", "example.com/core") + duplicate = self.module("src/other", "example.com/core") + with self.assertRaisesRegex(ValueError, "Duplicate Go module declaration"): + self.prepare(source=self.root / "src") + duplicate.write_text("not a module\n", encoding="utf8") + with self.assertRaisesRegex(ValueError, "Cannot parse"): + self.prepare(source=self.root / "src") + duplicate.unlink() + for name in ("vendor", ".git", ".venv", "build", "dist"): + self.module(f"{name}/nested", "example.com/core") + self.assertEqual(self.prepare(source=self.root / "src"), []) + + def test_compatible_major_versions_and_invalid_semantic_import_paths(self): + self.module("core", "example.com/core/v2") + consumer = self.module( + "consumer", + "example.com/consumer/v2", + "\nrequire example.com/core/v2 v2.1.0\nreplace example.com/core/v2 => ../core\n", + ) + with mock.patch.object(golang.logger, "warning") as warning: + self.assertEqual(self.prepare("2.2.0"), [consumer]) + warning.assert_not_called() + self.assertIn("example.com/core/v2 v2.2.0", consumer.read_text(encoding="utf8")) + + def test_release_within_major_three_updates_internal_requirement_without_warning(self): + self.module("core", "example.com/core/v3") + consumer = self.module( + "consumer", + "example.com/consumer/v3", + "\nrequire example.com/core/v3 v3.4.1\nreplace example.com/core/v3 => ../core\n", + ) + with mock.patch.object(golang.logger, "warning") as warning: + self.assertEqual(self.prepare("3.5.0"), [consumer]) + warning.assert_not_called() + self.assertIn("example.com/core/v3 v3.5.0", consumer.read_text(encoding="utf8")) + + def test_go_module_path_major_uses_semver_for_suffixes(self): + self.assertEqual(golang._go_module_path_major("example.com/core/v3"), 3) + self.assertEqual(golang._go_module_path_major("gopkg.in/core.v3"), 3) + self.assertIsNone(golang._go_module_path_major("example.com/core")) + self.assertFalse(golang._go_module_path_supports_major("example.com/core/v3", 1)) + self.assertFalse(golang._go_module_path_supports_major("gopkg.in/core.v3", 1)) + self.assertTrue(golang._go_module_path_supports_major("gopkg.in/core.v3", 3)) + + def test_gopkg_in_major_three_updates_normally(self): + self.module("core", "gopkg.in/core.v3") + consumer = self.module( + "consumer", + "example.com/consumer/v3", + "\nrequire gopkg.in/core.v3 v3.4.1\nreplace gopkg.in/core.v3 => ../core\n", + ) + self.assertEqual(self.prepare("3.5.0"), [consumer]) + self.assertIn("gopkg.in/core.v3 v3.5.0", consumer.read_text(encoding="utf8")) + + def test_major_three_to_four_requires_module_path_migration(self): + self.module("core", "example.com/core/v3") + consumer = self.module( + "consumer", + "example.com/consumer/v3", + "\nrequire example.com/core/v3 v3.4.1\nreplace example.com/core/v3 => ../core\n", + ) + with mock.patch.object(golang.logger, "warning") as warning: + with self.assertRaisesRegex(ValueError, "Migrate module declarations"): + self.prepare("4.0.0") + warning.assert_called_once() + self.assertIn("example.com/core/v3 v3.4.1", consumer.read_text(encoding="utf8")) + + def test_major_transition_warns_and_refuses_invalid_version(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../core\n", + ) + original = consumer.read_bytes() + with mock.patch.object(golang.logger, "warning") as warning: + with self.assertRaisesRegex(ValueError, "Migrate module declarations"): + self.prepare("2.0.0") + warning.assert_called_once() + self.assertEqual(consumer.read_bytes(), original) + + def test_invalid_path_for_lower_major_fails(self): + self.module("core", "example.com/core/v2") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core/v2 v2.1.0\nreplace example.com/core/v2 => ../core\n", + ) + with self.assertRaisesRegex(ValueError, "module path must match"): + self.prepare("1.3.0") + self.assertIn("v2.1.0", consumer.read_text(encoding="utf8")) + + def test_release_commit_stages_prepared_module_files(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../core\n", + ) + git = mock.Mock(root=self.root) + files_changed_by_release_preparation = self.prepare() + with mock.patch( + "continuous_delivery_scripts.tag_and_release.configuration.get_value", return_value="missing" + ), mock.patch("continuous_delivery_scripts.tag_and_release._commit_changes"): + _commit_release_changes(git, "1.3.0", "release", files_changed_by_release_preparation) + git.add.assert_any_call(consumer) + self.assertIn("example.com/core v1.3.0", consumer.read_text(encoding="utf8")) + + def test_preparation_precedes_release_documentation(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../core\n", + ) + + def config_value(variable): + return self.root if str(variable).endswith("PROJECT_ROOT") else False + + def documentation(): + self.assertIn("example.com/core v1.3.0", consumer.read_text(encoding="utf8")) + + with mock.patch.object(golang, "ROOT_DIR", self.root), mock.patch.object( + golang, "SRC_DIR", self.root + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.get_language_specifics", return_value=golang.Go() + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.configuration.get_value", side_effect=config_value + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.version_project", return_value=(True, "1.3.0", {}) + ), mock.patch( + "continuous_delivery_scripts.tag_and_release._update_documentation", side_effect=documentation + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.insert_licence_header" + ), mock.patch( + "continuous_delivery_scripts.tag_and_release._update_repository" + ) as update_repository, mock.patch( + "continuous_delivery_scripts.tag_and_release._clean_repository" + ), mock.patch.object( + golang.Go, "package_software" + ), mock.patch.object( + golang.Go, "release_package_to_repository" + ): + tag_and_release(CommitType.RELEASE) + self.assertEqual(update_repository.call_args.args[-1], [consumer]) + + def test_no_new_project_version_skips_release_preparation(self): + self.module("core", "example.com/core") + consumer = self.module("consumer", "example.com/consumer", "\nrequire example.com/core v1.2.0\n") + original = consumer.read_bytes() + with mock.patch.object(golang, "_candidate_go_module_directories") as candidates, mock.patch( + "continuous_delivery_scripts.tag_and_release.get_language_specifics", return_value=golang.Go() + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.configuration.get_value", + side_effect=lambda variable: self.root if variable == golang.ConfigurationVariable.PROJECT_ROOT else False, + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.version_project", return_value=(False, "1.3.0", {}) + ), mock.patch( + "continuous_delivery_scripts.tag_and_release._update_documentation" + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.insert_licence_header" + ), mock.patch( + "continuous_delivery_scripts.tag_and_release._update_repository" + ) as update_repository: + tag_and_release(CommitType.RELEASE) + candidates.assert_not_called() + self.assertEqual(consumer.read_bytes(), original) + self.assertEqual(update_repository.call_args.args[-1], []) + + def test_beta_preparation_uses_upcoming_project_version(self): + self.module("core", "example.com/core") + consumer = self.module( + "consumer", + "example.com/consumer", + "\nrequire example.com/core v1.2.0\nreplace example.com/core => ../core\n", + ) + with mock.patch.object(golang, "ROOT_DIR", self.root), mock.patch.object( + golang, "SRC_DIR", self.root + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.get_language_specifics", return_value=golang.Go() + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.configuration.get_value", + side_effect=lambda variable: self.root if variable == golang.ConfigurationVariable.PROJECT_ROOT else False, + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.version_project", return_value=(True, "1.3.0-beta.1", {}) + ), mock.patch( + "continuous_delivery_scripts.tag_and_release._update_documentation" + ), mock.patch( + "continuous_delivery_scripts.tag_and_release.insert_licence_header" + ), mock.patch( + "continuous_delivery_scripts.tag_and_release._update_repository" + ) as update_repository, mock.patch( + "continuous_delivery_scripts.tag_and_release._clean_repository" + ), mock.patch.object( + golang.Go, "package_software" + ), mock.patch.object( + golang.Go, "release_package_to_repository" + ): + tag_and_release(CommitType.BETA) + + self.assertIn("example.com/core v1.3.0-beta.1", consumer.read_text(encoding="utf8")) + self.assertEqual(update_repository.call_args.args[-1], [consumer]) diff --git a/tests/plugin/test_golang.py b/tests/plugin/test_golang.py index 1768bf8..226e703 100644 --- a/tests/plugin/test_golang.py +++ b/tests/plugin/test_golang.py @@ -69,12 +69,12 @@ def test_determine_go_module_tag_reads_multiple_go_work_files(self): 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: + def check_output_side_effect(command, encoding=None, stderr=None): + if Path(command[-1]) == source_dir / "go.work": return '{"Use": [{"DiskPath": "./app1"}]}' - if Path(str(cwd)) == root: + if Path(command[-1]) == root / "go.work": return '{"Use": [{"DiskPath": "./shared"}, {"DiskPath": "./root-app"}]}' - raise AssertionError(f"Unexpected cwd: {cwd}") + raise AssertionError(f"Unexpected workspace: {command[-1]}") with mock.patch.object(golang, "ROOT_DIR", root), mock.patch.object( golang, "SRC_DIR", source_dir @@ -112,6 +112,15 @@ def test_determine_go_work_module_directories_from_json_with_go(self): self.assertEqual(directories, [root.joinpath("app1")]) + def test_workspace_tag_discovery_uses_shared_metadata_reader(self): + workspace = Path("project/go.work") + with mock.patch.object( + golang, "_read_go_workspace_metadata", return_value={"Use": [{"DiskPath": "./app1"}]} + ) as read_metadata: + directories = golang._determine_go_work_module_directories_from_json(workspace) + self.assertEqual(directories, [workspace.parent / "app1"]) + read_metadata.assert_called_once_with(workspace) + def test_doc2go_discovers_actual_go_work_modules(self): with TemporaryDirectory() as temp_dir: root = Path(temp_dir) From f96446df7add86a81c56d43a3781af2f406f716b Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Mon, 19 Oct 2026 14:20:43 +0100 Subject: [PATCH 2/2] :bug: Type Go module metadata and major version returns --- continuous_delivery_scripts/plugins/golang.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/continuous_delivery_scripts/plugins/golang.py b/continuous_delivery_scripts/plugins/golang.py index 2e5fad2..084dcbc 100644 --- a/continuous_delivery_scripts/plugins/golang.py +++ b/continuous_delivery_scripts/plugins/golang.py @@ -79,10 +79,15 @@ def _go_edit_command(file: Path, subcommand: str, flags: List[str]) -> List[str] def _read_go_metadata_with_edit_command(file: Path, subcommand: str) -> dict: """Use Go's parser to inspect directives without resolving unpublished modules.""" try: - return json.loads(check_output(_go_edit_command(file, subcommand, ["-json"]), encoding="utf8", stderr=STDOUT)) + metadata = json.loads( + check_output(_go_edit_command(file, subcommand, ["-json"]), encoding="utf8", stderr=STDOUT) + ) except (CalledProcessError, ValueError, OSError) as error: detail = error.output if isinstance(error, CalledProcessError) else str(error) raise ValueError(f"Cannot parse {file} with Go tooling: {detail}") from error + if not isinstance(metadata, dict): + raise ValueError(f"Expected a JSON object from Go tooling for {file}") + return metadata def _read_go_module_metadata(go_mod_file: Path) -> dict: @@ -137,7 +142,7 @@ def _release_workspaces(root: Path) -> List[Path]: def _go_major(version: str) -> int: """Parse the major component of CDS or Go semver, including pseudo-versions.""" try: - return semver.VersionInfo.parse(version.removeprefix("v")).major + return int(semver.VersionInfo.parse(version.removeprefix("v")).major) except ValueError as error: raise ValueError(f"Invalid Go module version: {version!r}") from error