diff --git a/.gitignore b/.gitignore index cf8a199..51ad3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .devbox +.idea/ .venv/ __pycache__/ dist/ diff --git a/src/releaser/cli/commands/analyze_manifest.py b/src/releaser/cli/commands/analyze_manifest.py index a1b3e63..3f35c2d 100644 --- a/src/releaser/cli/commands/analyze_manifest.py +++ b/src/releaser/cli/commands/analyze_manifest.py @@ -1,4 +1,5 @@ """Create a release manifest.""" + from __future__ import annotations import argparse diff --git a/src/releaser/cli/commands/bake_manifest.py b/src/releaser/cli/commands/bake_manifest.py index 11ce9a3..dce2f14 100644 --- a/src/releaser/cli/commands/bake_manifest.py +++ b/src/releaser/cli/commands/bake_manifest.py @@ -1,4 +1,5 @@ """Create a release manifest.""" + from __future__ import annotations import argparse diff --git a/src/releaser/cli/commands/create_manifest.py b/src/releaser/cli/commands/create_manifest.py index c3760f8..a0aa784 100644 --- a/src/releaser/cli/commands/create_manifest.py +++ b/src/releaser/cli/commands/create_manifest.py @@ -1,4 +1,5 @@ """Create a release manifest.""" + from __future__ import annotations import argparse diff --git a/src/releaser/cli/commands/upload_manifest.py b/src/releaser/cli/commands/upload_manifest.py index 2617a59..c14aa2b 100644 --- a/src/releaser/cli/commands/upload_manifest.py +++ b/src/releaser/cli/commands/upload_manifest.py @@ -1,4 +1,5 @@ """Create a release manifest.""" + from __future__ import annotations import argparse diff --git a/src/releaser/hexagon/entities/strategy/__init__.py b/src/releaser/hexagon/entities/strategy/__init__.py index 5e54061..6975342 100644 --- a/src/releaser/hexagon/entities/strategy/__init__.py +++ b/src/releaser/hexagon/entities/strategy/__init__.py @@ -1,9 +1,13 @@ from __future__ import annotations -from .application import Application, ApplicationReleaseStrategy from .image import Image from .policy import CommitMsgMatchPolicy, GitCommitShaTag, LiteralTag, VersionTag -from .release_strategy import ReleaseStrategy +from .release_strategy import ( + Application, + ApplicationReleaseStrategy, + ReleaseStrategy, + Rule, +) __all__ = [ "Application", @@ -14,4 +18,5 @@ "VersionTag", "LiteralTag", "Image", + "Rule", ] diff --git a/src/releaser/hexagon/entities/strategy/application.py b/src/releaser/hexagon/entities/strategy/application.py deleted file mode 100644 index c3c40a3..0000000 --- a/src/releaser/hexagon/entities/strategy/application.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from .image import Image -from .policy import CommitMsgMatchPolicy - - -@dataclass -class Application: - """An application within a development repository.""" - - on_commit_msg: list[CommitMsgMatchPolicy] | None = None - """The tags to match for the HEAD commit.""" - - images: list[Image] | None = None - """The images produced by the application. - - Images must be a list of OCI-compliant image repositories. - """ - - @classmethod - def parse_dict(cls, data: dict[str, Any]) -> "Application": - """Parse an application from a dictionary.""" - policies: list[Any] | dict[str, Any] = data.get("on_commit_msg", []) - if isinstance(policies, list): - match_policies = [ - CommitMsgMatchPolicy.parse_dict(policy) for policy in policies - ] - else: - match_policies = [CommitMsgMatchPolicy.parse_dict(policies)] - return cls( - on_commit_msg=match_policies, - images=[_asimage(image) for image in data.get("images", [])], - ) - - -@dataclass -class ApplicationReleaseStrategy: - name: str - """The name of the application.""" - - match_commit_msg: list[CommitMsgMatchPolicy] - """The tags to match for the HEAD commit.""" - - images: list[Image] - """The images produced by the application. - - Images must be a list of OCI-compliant image repositories. - """ - - -def _asimage(value: str | dict[str, Any]) -> Image: - """Convert a value to an image.""" - if isinstance(value, str): - return Image(repository=value) - return Image.parse_dict(value) diff --git a/src/releaser/hexagon/entities/strategy/policy.py b/src/releaser/hexagon/entities/strategy/policy.py index 9d72824..360a539 100644 --- a/src/releaser/hexagon/entities/strategy/policy.py +++ b/src/releaser/hexagon/entities/strategy/policy.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Literal @@ -93,11 +93,15 @@ def parse_dict(cls, data: dict[str, Any]) -> "CommitMsgMatchPolicy": return cls( match=_aslist(data["match"]), tags=[ - VersionTag.parse_dict(tag) - if tag.get("type") == "version" - else GitCommitShaTag.parse_dict(tag) - if tag.get("type") == "git_commit_sha" - else LiteralTag.parse_dict(tag) + ( + VersionTag.parse_dict(tag) + if tag.get("type") == "version" + else ( + GitCommitShaTag.parse_dict(tag) + if tag.get("type") == "git_commit_sha" + else LiteralTag.parse_dict(tag) + ) + ) for tag in data["tags"] ], filter=data.get("filter"), @@ -105,6 +109,31 @@ def parse_dict(cls, data: dict[str, Any]) -> "CommitMsgMatchPolicy": ) +@dataclass(frozen=True) +class Rule: + """A rule for a release strategy.""" + + branches: list[str] = field(default_factory=list) + """The branches to match for the HEAD commit message.""" + + commit_msg: list[CommitMsgMatchPolicy] = field(default_factory=list) + """The tags to match for the HEAD commit message. + These are global policies that apply to all applications. + """ + + @classmethod + def parse_dict(cls, data: dict[str, Any]) -> Rule: + """Parse a rule from a dictionary.""" + + return cls( + branches=_aslist(data.get("branch")), + commit_msg=[ + CommitMsgMatchPolicy.parse_dict(policy) + for policy in data.get("commit_msg", []) + ], + ) + + def _aslist(any: Any) -> list[Any]: """Convert a value to a list.""" if any is None: diff --git a/src/releaser/hexagon/entities/strategy/release_strategy.py b/src/releaser/hexagon/entities/strategy/release_strategy.py index fde1a57..f3b43ec 100644 --- a/src/releaser/hexagon/entities/strategy/release_strategy.py +++ b/src/releaser/hexagon/entities/strategy/release_strategy.py @@ -3,8 +3,46 @@ from dataclasses import dataclass, field from typing import Any -from .application import Application, ApplicationReleaseStrategy -from .policy import CommitMsgMatchPolicy +from .image import Image +from .policy import Rule + + +@dataclass +class Application: + """An application within a development repository.""" + + on: list[Rule] | None = None + """The tags to match for the HEAD commit.""" + + images: list[Image] | None = None + """The images produced by the application. + + Images must be a list of OCI-compliant image repositories. + """ + + @classmethod + def parse_dict(cls, data: dict[str, Any]) -> "Application": + """Parse an application from a dictionary.""" + on = [Rule.parse_dict(rule) for rule in data.get("on", {}).values()] + return cls( + on=on, + images=[_asimage(image) for image in data.get("images", [])], + ) + + +@dataclass +class ApplicationReleaseStrategy: + name: str + """The name of the application.""" + + on: list[Rule] + """The tags to match for the HEAD commit.""" + + images: list[Image] + """The images produced by the application. + + Images must be a list of OCI-compliant image repositories. + """ @dataclass(frozen=True) @@ -14,9 +52,9 @@ class ReleaseStrategy: applications: dict[str, Application] """The applications in the repository. Each application may define a custom release strategy.""" - on_commit_msg: list[CommitMsgMatchPolicy] = field(default_factory=list) - """The tags to match for the HEAD commit message. - These are global policies that apply to all applications. + on: list[Rule] = field(default_factory=list) + """The rules to set tags. + These are global rules that apply to all applications. """ @classmethod @@ -27,10 +65,7 @@ def parse_dict(cls, data: dict[str, Any]) -> "ReleaseStrategy": name: Application.parse_dict(application) for name, application in data.get("applications", {}).items() }, - on_commit_msg=[ - CommitMsgMatchPolicy.parse_dict(policy) - for policy in data.get("on_commit_msg", []) - ], + on=[Rule.parse_dict(rule) for rule in data.get("on", {}).values()], ) def get_release_strategy_for_application( @@ -41,8 +76,7 @@ def get_release_strategy_for_application( if app_name == name: return ApplicationReleaseStrategy( name=app_name, - match_commit_msg=_aslist(application.on_commit_msg) - or self.on_commit_msg, + on=_aslist(application.on) or self.on, images=application.images or [], ) raise ValueError(f"Application {name} not found in release strategy") @@ -55,3 +89,10 @@ def _aslist(any: Any) -> list[Any]: if not isinstance(any, list): return [any] return any # type: ignore + + +def _asimage(value: str | dict[str, Any]) -> Image: + """Convert a value to an image.""" + if isinstance(value, str): + return Image(repository=value) + return Image.parse_dict(value) diff --git a/src/releaser/hexagon/errors.py b/src/releaser/hexagon/errors.py index 324eca8..4e89e0a 100644 --- a/src/releaser/hexagon/errors.py +++ b/src/releaser/hexagon/errors.py @@ -1,4 +1,5 @@ """Custom errors for releaser hexagon.""" + from __future__ import annotations diff --git a/src/releaser/hexagon/ports/git_reader.py b/src/releaser/hexagon/ports/git_reader.py index d341a86..f46140a 100644 --- a/src/releaser/hexagon/ports/git_reader.py +++ b/src/releaser/hexagon/ports/git_reader.py @@ -1,4 +1,5 @@ """This module defines the GitReader abstract base class.""" + from __future__ import annotations import abc @@ -11,6 +12,11 @@ def is_dirty(self) -> bool: """Check if the repository has uncommitted changes.""" raise NotImplementedError + @abc.abstractmethod + def read_current_branch(self) -> str: + """Read the name of the current branch.""" + raise NotImplementedError + @abc.abstractmethod def read_most_recent_commit_sha(self) -> str: """Read the SHA of the latest commit.""" @@ -33,3 +39,9 @@ def read_last_commit_message(self, depth: int, filter: str | None) -> str | None return commit_msg return None return history[0] + + def current_reference_matches(self, ref: list[str]) -> bool: + """Check if the current reference matches the given ref.""" + if not ref: + return True + return self.read_current_branch() in ref diff --git a/src/releaser/hexagon/ports/json_writer.py b/src/releaser/hexagon/ports/json_writer.py index e5a4841..a90484c 100644 --- a/src/releaser/hexagon/ports/json_writer.py +++ b/src/releaser/hexagon/ports/json_writer.py @@ -1,4 +1,5 @@ """This module defines the JsonWriter abstract base class.""" + from __future__ import annotations import abc diff --git a/src/releaser/hexagon/ports/strategy_reader.py b/src/releaser/hexagon/ports/strategy_reader.py index f07e2f8..67ea6c4 100644 --- a/src/releaser/hexagon/ports/strategy_reader.py +++ b/src/releaser/hexagon/ports/strategy_reader.py @@ -1,4 +1,5 @@ """This module defines the StrategyReader abstract base class.""" + from __future__ import annotations import abc diff --git a/src/releaser/hexagon/ports/version_reader.py b/src/releaser/hexagon/ports/version_reader.py index aaa34c9..c9579d1 100644 --- a/src/releaser/hexagon/ports/version_reader.py +++ b/src/releaser/hexagon/ports/version_reader.py @@ -1,4 +1,5 @@ """This module defines the VersionReader abstract base class.""" + from __future__ import annotations import abc diff --git a/src/releaser/hexagon/ports/webhook_client.py b/src/releaser/hexagon/ports/webhook_client.py index 1203128..25b60bf 100644 --- a/src/releaser/hexagon/ports/webhook_client.py +++ b/src/releaser/hexagon/ports/webhook_client.py @@ -1,4 +1,5 @@ """This module defines the HttpClient abstract base class.""" + from __future__ import annotations import abc diff --git a/src/releaser/hexagon/services/manifest_baker.py b/src/releaser/hexagon/services/manifest_baker.py index a3956f0..3e7b3e8 100644 --- a/src/releaser/hexagon/services/manifest_baker.py +++ b/src/releaser/hexagon/services/manifest_baker.py @@ -1,6 +1,5 @@ """Service used to build manfest artefacts.""" - from dataclasses import dataclass from ..entities import bakery, strategy diff --git a/src/releaser/hexagon/services/manifest_generator.py b/src/releaser/hexagon/services/manifest_generator.py index 658e9ed..92672b6 100644 --- a/src/releaser/hexagon/services/manifest_generator.py +++ b/src/releaser/hexagon/services/manifest_generator.py @@ -1,4 +1,5 @@ """Service used to generate manifest during a release.""" + from __future__ import annotations import re @@ -75,13 +76,18 @@ def _generate_application_tags( self, application: strategy.ApplicationReleaseStrategy ) -> Iterator[str]: """Generate the list of tags to release for an application.""" - for policy in application.match_commit_msg: - msg = self.git_reader.read_last_commit_message(policy.depth, policy.filter) - if not msg: + for rule in application.on: + if not self.git_reader.current_reference_matches(rule.branches): continue - if self._verify_commit_msg_match_against_policy(msg, policy): - for tag in policy.tags: - yield self._get_value_for_tag(tag) + for policy in rule.commit_msg: + msg = self.git_reader.read_last_commit_message( + policy.depth, policy.filter + ) + if not msg: + continue + if self._verify_commit_msg_match_against_policy(msg, policy): + for tag in policy.tags: + yield self._get_value_for_tag(tag) def _verify_commit_msg_match_against_policy( self, commit_msg: str, policy: CommitMsgMatchPolicy diff --git a/src/releaser/hexagon/services/manifest_notifier.py b/src/releaser/hexagon/services/manifest_notifier.py index 7daea1b..f53815d 100644 --- a/src/releaser/hexagon/services/manifest_notifier.py +++ b/src/releaser/hexagon/services/manifest_notifier.py @@ -1,5 +1,6 @@ """Service used to send a POST request to a webhook URL with a manifest as JSON payload.""" + from __future__ import annotations from dataclasses import dataclass diff --git a/src/releaser/infra/git_reader/stub.py b/src/releaser/infra/git_reader/stub.py index 5733ab9..65cb7b7 100644 --- a/src/releaser/infra/git_reader/stub.py +++ b/src/releaser/infra/git_reader/stub.py @@ -10,6 +10,12 @@ def __init__(self) -> None: self._sha: str | None = None self._history: list[str] | None = None self._is_dirty: bool | None = None + self._branch: str | None = None + + def read_current_branch(self) -> str: + if self._branch is None: + raise RuntimeError("branch not set in stub git reader") + return self._branch def is_dirty(self) -> bool: if self._is_dirty is None: @@ -37,3 +43,7 @@ def set_history(self, history: list[str]) -> None: def set_is_dirty(self, is_dirty: bool) -> None: """Test helper: Set the value that will be returned by is_dirty.""" self._is_dirty = is_dirty + + def set_branch(self, branch: str) -> None: + """ Test helper : set the value that be returned by read_current_branch.""" + self._branch = branch diff --git a/src/releaser/infra/git_reader/subprocess.py b/src/releaser/infra/git_reader/subprocess.py index 85d00fe..29c5c4b 100644 --- a/src/releaser/infra/git_reader/subprocess.py +++ b/src/releaser/infra/git_reader/subprocess.py @@ -18,6 +18,10 @@ def is_dirty(self) -> bool: process = subprocess.run(["git", "diff-index", "--quiet", "HEAD", "--"]) return process.returncode != 0 + def read_current_branch(self) -> str: + branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + return branch.decode().strip() + def read_most_recent_commit_sha(self) -> str: long_sha = ( subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip() diff --git a/src/releaser/infra/json_writer/stdout.py b/src/releaser/infra/json_writer/stdout.py index 7469d2c..8705bd6 100644 --- a/src/releaser/infra/json_writer/stdout.py +++ b/src/releaser/infra/json_writer/stdout.py @@ -8,7 +8,6 @@ class JsonStdoutWriter(JsonWriter): - """A JSON writer that writes to the standard output.""" def write_manifest(self, manifest: artefact.Manifest) -> None: diff --git a/tests/test_cli/regression_test_data/output/manifest_quara-app-branch_next.json b/tests/test_cli/regression_test_data/output/manifest_quara-app-branch_next.json new file mode 100644 index 0000000..3b7c139 --- /dev/null +++ b/tests/test_cli/regression_test_data/output/manifest_quara-app-branch_next.json @@ -0,0 +1,36 @@ +{ + "applications": { + "acquisition": { + "images": [ + { + "repository": "quara.azurecr.io/quara-acquisition-app", + "image": "quara.azurecr.io/quara-acquisition-app:next", + "tag": "next", + "platforms": {} + }, + { + "repository": "quara.azurecr.io/quara-acquisition-app", + "image": "quara.azurecr.io/quara-acquisition-app:shatest", + "tag": "shatest", + "platforms": {} + } + ] + }, + "operating-app": { + "images": [ + { + "repository": "quara.azurecr.io/quara-operator-app", + "image": "quara.azurecr.io/quara-operator-app:next", + "tag": "next", + "platforms": {} + }, + { + "repository": "quara.azurecr.io/quara-operator-app", + "image": "quara.azurecr.io/quara-operator-app:shatest", + "tag": "shatest", + "platforms": {} + } + ] + } + } +} \ No newline at end of file diff --git a/tests/test_cli/regression_test_data/output/manifest_quara-frontend-branch_next.json b/tests/test_cli/regression_test_data/output/manifest_quara-frontend-branch_next.json new file mode 100644 index 0000000..5ff9824 --- /dev/null +++ b/tests/test_cli/regression_test_data/output/manifest_quara-frontend-branch_next.json @@ -0,0 +1,36 @@ +{ + "applications": { + "quara-frontend": { + "images": [ + { + "repository": "quara.azurecr.io/quara-frontend", + "image": "quara.azurecr.io/quara-frontend:shatest", + "tag": "shatest", + "platforms": {} + }, + { + "repository": "quara.azurecr.io/quara-frontend", + "image": "quara.azurecr.io/quara-frontend:next", + "tag": "next", + "platforms": {} + } + ] + }, + "quara-frontend-storybook": { + "images": [ + { + "repository": "quara.azurecr.io/quara-storybook", + "image": "quara.azurecr.io/quara-storybook:shatest", + "tag": "shatest", + "platforms": {} + }, + { + "repository": "quara.azurecr.io/quara-storybook", + "image": "quara.azurecr.io/quara-storybook:next", + "tag": "next", + "platforms": {} + } + ] + } + } +} \ No newline at end of file diff --git a/tests/test_cli/regression_test_data/output/manifest_quara-python-branch_next.json b/tests/test_cli/regression_test_data/output/manifest_quara-python-branch_next.json new file mode 100644 index 0000000..7505b86 --- /dev/null +++ b/tests/test_cli/regression_test_data/output/manifest_quara-python-branch_next.json @@ -0,0 +1,54 @@ +{ + "applications": { + "quara-all-in-one": { + "images": [ + { + "repository": "quara.azurecr.io/quara-all-in-one", + "image": "quara.azurecr.io/quara-all-in-one:next", + "tag": "next", + "platforms": {} + }, + { + "repository": "quara.azurecr.io/quara-all-in-one", + "image": "quara.azurecr.io/quara-all-in-one:shatest", + "tag": "shatest", + "platforms": {} + } + ] + }, + "quara-ble-gateway": { + "images": [ + { + "repository": "quara.azurecr.io/quara-ble-gateway", + "image": "quara.azurecr.io/quara-ble-gateway:next", + "tag": "next", + "platforms": { + "linux/amd64": { + "image": "quara.azurecr.io/quara-ble-gateway:next-amd64", + "tag": "next-amd64" + }, + "linux/arm64": { + "image": "quara.azurecr.io/quara-ble-gateway:next-arm64", + "tag": "next-arm64" + } + } + }, + { + "repository": "quara.azurecr.io/quara-ble-gateway", + "image": "quara.azurecr.io/quara-ble-gateway:shatest", + "tag": "shatest", + "platforms": { + "linux/amd64": { + "image": "quara.azurecr.io/quara-ble-gateway:shatest-amd64", + "tag": "shatest-amd64" + }, + "linux/arm64": { + "image": "quara.azurecr.io/quara-ble-gateway:shatest-arm64", + "tag": "shatest-arm64" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/test_cli/regression_test_data/quara-app.package.json b/tests/test_cli/regression_test_data/quara-app.package.json new file mode 100644 index 0000000..17b5edd --- /dev/null +++ b/tests/test_cli/regression_test_data/quara-app.package.json @@ -0,0 +1,105 @@ +{ + "quara": { + "schemas": { + "namespace": "quara", + "reference": "next", + "storageAccount": "devquacomapijqtcetlst0" + }, + "releaser": { + "on": { + "all": { + "commit_msg": [ + { + "match": "*", + "tags": [ + { + "type": "git_commit_sha", + "size": 7 + } + ] + } + ] + } + }, + "applications": { + "operating-app": { + "images": [ + "quara.azurecr.io/quara-operator-app" + ], + "on": { + "continuous-delivery": { + "branches": [ + "next" + ], + "commit_msg": [ + { + "match": "chore\\(release\\): [a-z-A-Z]* [0-9].[0-9]*.[0-9]", + "tags": [ + { + "value": "latest" + }, + { + "type": "version", + "file": "apps/operating-app/version.json" + } + ] + }, + { + "match": "*", + "tags": [ + { + "value": "next" + }, + { + "type": "git_commit_sha", + "size": 7 + } + ] + } + ] + } + } + }, + "acquisition": { + "images": [ + "quara.azurecr.io/quara-acquisition-app" + ], + "on": { + "all": {}, + "continuous-delivery": { + "branches": [ + "next" + ], + "commit_msg": [ + { + "match": "chore\\(release\\): [a-z-A-Z]* [0-9].[0-9]*.[0-9]", + "tags": [ + { + "value": "latest" + }, + { + "type": "version", + "file": "apps/acquisition/version.json" + } + ] + }, + { + "match": "*", + "tags": [ + { + "value": "next" + }, + { + "type": "git_commit_sha", + "size": 7 + } + ] + } + ] + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/test_cli/regression_test_data/quara-frontend.package.json b/tests/test_cli/regression_test_data/quara-frontend.package.json new file mode 100644 index 0000000..a2705f5 --- /dev/null +++ b/tests/test_cli/regression_test_data/quara-frontend.package.json @@ -0,0 +1,90 @@ +{ + "quara": { + "schemas": { + "namespace": "quara", + "reference": "next", + "storageAccount": "devquacomapijqtcetlst0" + }, + "releaser": { + "on": { + "all": { + "commit_msg": [ + { + "match": "*", + "tags": [ + { + "type": "git_commit_sha", + "size": 7 + } + ] + } + ] + }, + "continuous-delivery": { + "branches": [ + "next" + ], + "commit_msg": [ + { + "match": "*", + "tags": [ + { + "value": "next" + } + ] + }, + { + "match": [ + "chore\\(release\\): bumped to version [0-9]*.[0-9]*.[0-9]*\\-rc\\.[0-9]*\\s" + ], + "tags": [ + { + "value": "edge" + } + ] + }, + { + "match": [ + "chore\\(release\\): bumped to version [0-9]*.[0-9]*.[0-9]*", + "chore: merge from stable branch" + ], + "tags": [ + { + "type": "version" + } + ] + }, + { + "match": [ + "chore\\(release\\): bumped to version [0-9]*.[0-9]*.[0-9]*\\s" + ], + "tags": [ + { + "value": "latest" + } + ] + } + ] + } + }, + "applications": { + "quara-frontend": { + "images": [ + { + "repository": "quara.azurecr.io/quara-frontend", + "dockerfile": "docker/Dockerfile" + } + ] + }, + "quara-frontend-storybook": { + "images": [ + { + "repository": "quara.azurecr.io/quara-storybook", + "dockerfile": "docker/storybook/Dockerfile" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/test_cli/regression_test_data/quara-python.pyproject.toml b/tests/test_cli/regression_test_data/quara-python.pyproject.toml new file mode 100644 index 0000000..8fabc3d --- /dev/null +++ b/tests/test_cli/regression_test_data/quara-python.pyproject.toml @@ -0,0 +1,42 @@ + +[tool.quara.releaser.on.continuous-delivery] +branch = ["next"] +commit_msg = [ + { match = "*", tags = [ + { value = "next" } + ] }, + { match = [ + "chore\\(release\\): bump to version [0-9]*.[0-9]*.[0-9]*\\-rc\\.[0-9]*\\s", + ], tags = [ + { value = "edge" } + ] }, + { match = [ + "chore\\(release\\): bump to version [0-9]*.[0-9]*.[0-9]*", + "chore\\(release\\): merge from stable branch", + ], tags = [ + { type = "version" } + ] }, + { match = [ + "chore\\(release\\): bump to version [0-9]*.[0-9]*.[0-9]*\\s", + ], tags = [ + { value = "latest" } + ] }, +] + +[tool.quara.releaser.on.all] +commit_msg = [ + { match = "*", tags = [ + { type = "git_commit_sha", size = 7 } + ] } +] + +[tool.quara.releaser.applications.quara-all-in-one] +images = [{ repository = "quara.azurecr.io/quara-all-in-one" }] + +[tool.quara.releaser.applications.quara-ble-gateway] +images = [ + { repository = "quara.azurecr.io/quara-ble-gateway", platforms = [ + "linux/amd64", + "linux/arm64", + ] }, +] \ No newline at end of file diff --git a/tests/test_cli/test_create_manifest_command.py b/tests/test_cli/test_create_manifest_command.py index d9b17d3..7711b81 100644 --- a/tests/test_cli/test_create_manifest_command.py +++ b/tests/test_cli/test_create_manifest_command.py @@ -1,8 +1,11 @@ from __future__ import annotations +import json import shlex +from pathlib import Path import pytest +import toml from releaser.cli.app import Application from releaser.hexagon.entities import artefact, strategy @@ -21,24 +24,71 @@ def setup( testing_dependencies=self.deps, ) + def read_manifest(self) -> artefact.Manifest: + """Get the generated manifest.""" + generated = self.deps.manifest_writer.read_manifest() + if generated is None: + raise ValueError("Manifest is not generated") + return generated + def assert_manifest(self, expected: dict[str, object]) -> None: - """An assertion helper to check the manifest contents.""" - assert ( - self.deps.manifest_writer.read_manifest() - == artefact.Manifest.parse_dict(expected) - ) + """Assert the generated manifest.""" + generated = self.read_manifest() + assert generated == generated.parse_dict(expected) def set_strategy(self, values: dict[str, object]) -> None: """A helper to set the release strategy during test.""" valid_strategy = strategy.ReleaseStrategy.parse_dict(values) self.deps.strategy_reader.set_strategy(valid_strategy) + def set_git_state( + self, + branch: str, + history: list[str], + sha: str | None = None, + dirty: bool = False, + ): + """a helper to set git state""" + self.deps.git_reader.set_history(history) + if sha is not None: + self.deps.git_reader.set_sha(sha) + self.deps.git_reader.set_branch(branch) + self.deps.git_reader.set_is_dirty(dirty) + def run_command(self, command: str) -> None: """A helper to run a command during test.""" self.app.execute(shlex.split(command)) + @staticmethod + def read_test_file(*path: str) -> str: + """read file regarding path""" + absolute_path = Path(__file__).parent.joinpath(*path) + return absolute_path.read_text() + + @classmethod + def read_package_json(cls, file_name: str) -> dict[str, object]: + """read packages json for regression test""" + content = json.loads(cls.read_test_file("regression_test_data", file_name)) + return content["quara"]["releaser"] + + @classmethod + def read_pyproject_toml(cls, file_name: str) -> dict[str, object]: + """a helper that read and return a toml object from file""" + content = toml.loads(cls.read_test_file("regression_test_data", file_name)) + return content["tool"]["quara"]["releaser"] + + @classmethod + def read_releaser_config(cls, file_name: str) -> dict[str, object]: + extension = file_name.split(".")[-1] + if extension == "toml": + return cls.read_pyproject_toml(file_name) + elif extension == "json": + return cls.read_package_json(file_name) + raise TypeError(f"Unknown file extension: {extension}") + class TestCreateManifestCommand(CreateManifestCommandSetup): + def test_it_should_create_an_empty_manifest(self): self.set_strategy( {"applications": {}}, @@ -51,3 +101,46 @@ def test_it_should_create_an_empty_manifest(self): "applications": {}, } ) + + @pytest.mark.parametrize( + "strategy_file_name, branch, output_file_name", + [ + ( + "quara-frontend.package.json", + "next", + "manifest_quara-frontend-branch_next.json", + ), + ( + "quara-app.package.json", + "next", + "manifest_quara-app-branch_next.json", + ), + ( + "quara-python.pyproject.toml", + "next", + "manifest_quara-python-branch_next.json", + ), + ], + ) + def test_json_manifest( + self, + strategy_file_name: str, + branch: str, + output_file_name: str, + ): + expected_output = self.read_test_file( + "regression_test_data", "output", output_file_name + ) + # Arrange + self.set_strategy(self.read_releaser_config(strategy_file_name)) + self.set_git_state( + branch=branch, + history=["this is the latest commit message"], + sha="shatest", + ) + # Act + self.run_command( + "create-manifest", + ) + # Assert + self.assert_manifest(json.loads(expected_output)) diff --git a/tests/test_hexagon/test_manifest_generator.py b/tests/test_hexagon/test_manifest_generator.py index 06bec5f..1b6372e 100644 --- a/tests/test_hexagon/test_manifest_generator.py +++ b/tests/test_hexagon/test_manifest_generator.py @@ -66,24 +66,88 @@ def test_without_image( assert manifest is not None assert manifest.applications == {} + @pytest.mark.parametrize( + "branches,output", + [ + ( + [], + artefact.Manifest( + applications={ + "test-app": artefact.Application( + images=[ + artefact.Image( + repository="test-image", + image="test-image:head", + tag="head", + platforms={}, + ) + ] + ) + } + ), + ), + (["something_else"], artefact.Manifest(applications={})), + ( + ["next"], + artefact.Manifest( + applications={ + "test-app": artefact.Application( + images=[ + artefact.Image( + repository="test-image", + image="test-image:head", + tag="head", + platforms={}, + ) + ] + ) + } + ), + ), + ( + ["something_else", "next"], + artefact.Manifest( + applications={ + "test-app": artefact.Application( + images=[ + artefact.Image( + repository="test-image", + image="test-image:head", + tag="head", + platforms={}, + ) + ] + ) + } + ), + ), + ], + ) def test_with_single_image_and_literal_commit_msg_matcher( - self, + self, branches: list[str], output: artefact.Manifest ): # Arrange self.git_reader.set_is_dirty(False) self.git_reader.set_history(["this is the latest commit message"]) + self.git_reader.set_branch("next") self.strategy_reader.set_strategy( strategy.ReleaseStrategy( applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), + on=[ + strategy.Rule( + branches=branches, + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ) + ], + ) ], ) - }, + } ) ) # Act @@ -91,17 +155,7 @@ def test_with_single_image_and_literal_commit_msg_matcher( # Assert manifest = self.json_writer.read_manifest() assert manifest is not None - assert manifest.applications == { - "test-app": artefact.Application( - images=[ - artefact.Image( - repository="test-image", - tag="head", - image="test-image:head", - ) - ] - ) - } + assert manifest == output @pytest.mark.parametrize( "last_commit", ["the first pattern is ...", "the second pattern is ..."] @@ -118,13 +172,19 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_all_matcher( applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.GitCommitShaTag(size=7)] - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ), + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ] + ) ], ) }, @@ -164,14 +224,19 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_single_matcher( applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), - strategy.CommitMsgMatchPolicy( - match=["this is the latest commit"], - tags=[strategy.GitCommitShaTag(size=7)], - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ), + strategy.CommitMsgMatchPolicy( + match=["this is the latest commit"], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ] + ) ], ) }, @@ -211,14 +276,19 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_single_matcher_no applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), - strategy.CommitMsgMatchPolicy( - match=["the expected pattern"], - tags=[strategy.GitCommitShaTag(size=7)], - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ), + strategy.CommitMsgMatchPolicy( + match=["the expected pattern"], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ] + ) ], ) }, @@ -256,14 +326,22 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_many_matchers( applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), - strategy.CommitMsgMatchPolicy( - match=["the first pattern", "the second pattern"], - tags=[strategy.GitCommitShaTag(size=7)], - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ), + strategy.CommitMsgMatchPolicy( + match=[ + "the first pattern", + "the second pattern", + ], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ] + ) ], ) }, @@ -305,16 +383,21 @@ def test_with_single_image_and_version_plus_git_tag_sha_policy_matcher_with_all_ applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), - strategy.CommitMsgMatchPolicy( - match=["*"], - tags=[ - strategy.VersionTag(), - strategy.GitCommitShaTag(size=7), - ], + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ), + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[ + strategy.VersionTag(), + strategy.GitCommitShaTag(size=7), + ], + ), + ] ), ], ) @@ -362,18 +445,23 @@ def test_with_single_image_and_version_plus_git_sha_tag_policy_policy_matcher_wi applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="head")] - ), - strategy.CommitMsgMatchPolicy( - match=["chore\\(release\\): bump to version"], - tags=[ - strategy.LiteralTag(value="edge"), - strategy.VersionTag(), - strategy.GitCommitShaTag(size=7), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[strategy.LiteralTag(value="head")], + ), + strategy.CommitMsgMatchPolicy( + match=["chore\\(release\\): bump to version"], + tags=[ + strategy.LiteralTag(value="edge"), + strategy.VersionTag(), + strategy.GitCommitShaTag(size=7), + ], + ), ], - ), + ) ], ) }, @@ -431,15 +519,19 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_all_matcher( applications={ "test-app": strategy.Application( images=[strategy.Image("test-image")], - ) + ), }, - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], - tags=[ - strategy.LiteralTag(value="next"), - strategy.GitCommitShaTag(size=7), - ], + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], + tags=[ + strategy.LiteralTag(value="next"), + strategy.GitCommitShaTag(size=7), + ], + ) + ] ) ], ) @@ -480,14 +572,18 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_single_matcher( images=[strategy.Image("test-image")], ) }, - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="next")] - ), - strategy.CommitMsgMatchPolicy( - match=["this is the latest commit"], - tags=[strategy.GitCommitShaTag(size=7)], - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], tags=[strategy.LiteralTag(value="next")] + ), + strategy.CommitMsgMatchPolicy( + match=["this is the latest commit"], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ], + ) ], ) ) @@ -527,14 +623,18 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_single_matcher_no images=[strategy.Image("test-image")], ) }, - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="next")] - ), - strategy.CommitMsgMatchPolicy( - match=["the expected pattern"], - tags=[strategy.GitCommitShaTag(size=7)], - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], tags=[strategy.LiteralTag(value="next")] + ), + strategy.CommitMsgMatchPolicy( + match=["the expected pattern"], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ], + ) ], ) ) @@ -572,14 +672,18 @@ def test_with_single_image_and_git_sha_tag_policy_matcher_with_many_matchers( images=[strategy.Image("test-image")], ) }, - on_commit_msg=[ - strategy.CommitMsgMatchPolicy( - match=["*"], tags=[strategy.LiteralTag(value="next")] - ), - strategy.CommitMsgMatchPolicy( - match=["the first pattern", "the second pattern"], - tags=[strategy.GitCommitShaTag(size=7)], - ), + on=[ + strategy.Rule( + commit_msg=[ + strategy.CommitMsgMatchPolicy( + match=["*"], tags=[strategy.LiteralTag(value="next")] + ), + strategy.CommitMsgMatchPolicy( + match=["the first pattern", "the second pattern"], + tags=[strategy.GitCommitShaTag(size=7)], + ), + ], + ) ], ) )