auto-semver-control 1.8.7.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. auto_semver/__init__.py +5 -0
  2. auto_semver/adapters/__init__.py +4 -0
  3. auto_semver/adapters/git/__init__.py +6 -0
  4. auto_semver/adapters/git/base.py +22 -0
  5. auto_semver/adapters/git/constants.py +27 -0
  6. auto_semver/adapters/git/local.py +450 -0
  7. auto_semver/adapters/git/ops.py +17 -0
  8. auto_semver/adapters/git/promote.py +668 -0
  9. auto_semver/adapters/git/release_pr.py +668 -0
  10. auto_semver/adapters/git/verified.py +491 -0
  11. auto_semver/adapters/github/__init__.py +6 -0
  12. auto_semver/adapters/github/event.py +212 -0
  13. auto_semver/cli/__init__.py +6 -0
  14. auto_semver/cli/__main__.py +9 -0
  15. auto_semver/cli/bump.py +448 -0
  16. auto_semver/cli/finalize.py +223 -0
  17. auto_semver/cli/main.py +281 -0
  18. auto_semver/cli/promote.py +211 -0
  19. auto_semver/cli/setup.py +154 -0
  20. auto_semver/cli/utils.py +161 -0
  21. auto_semver/config/__init__.py +48 -0
  22. auto_semver/config/_models/__init__.py +35 -0
  23. auto_semver/config/_models/bump.py +22 -0
  24. auto_semver/config/_models/changelog.py +104 -0
  25. auto_semver/config/_models/commit_group.py +160 -0
  26. auto_semver/config/_models/commit_groups.py +63 -0
  27. auto_semver/config/_models/config.py +247 -0
  28. auto_semver/config/_models/lock_sync.py +48 -0
  29. auto_semver/config/_models/promotion.py +72 -0
  30. auto_semver/config/_models/pull_request.py +186 -0
  31. auto_semver/config/_models/release.py +31 -0
  32. auto_semver/config/config.py +128 -0
  33. auto_semver/config/constants.py +16 -0
  34. auto_semver/core/__init__.py +4 -0
  35. auto_semver/core/changelog/__init__.py +6 -0
  36. auto_semver/core/changelog/manager.py +289 -0
  37. auto_semver/core/commits/__init__.py +7 -0
  38. auto_semver/core/commits/grouper.py +276 -0
  39. auto_semver/core/commits/parser.py +164 -0
  40. auto_semver/core/pr/__init__.py +12 -0
  41. auto_semver/core/pr/builder.py +92 -0
  42. auto_semver/core/pr/github_builder.py +86 -0
  43. auto_semver/core/semver/__init__.py +8 -0
  44. auto_semver/core/semver/lock.py +200 -0
  45. auto_semver/core/semver/updater.py +75 -0
  46. auto_semver/core/semver/version.py +466 -0
  47. auto_semver/lock_sync/__init__.py +40 -0
  48. auto_semver/lock_sync/patch.py +206 -0
  49. auto_semver/lock_sync/pep440.py +56 -0
  50. auto_semver/lock_sync/registry.py +78 -0
  51. auto_semver/lock_sync/runner.py +100 -0
  52. auto_semver/lock_sync/strategies/__init__.py +17 -0
  53. auto_semver/lock_sync/strategies/npm.py +41 -0
  54. auto_semver/lock_sync/strategies/uv.py +41 -0
  55. auto_semver/lock_sync/strategy.py +59 -0
  56. auto_semver/lock_sync/sync.py +178 -0
  57. auto_semver/py.typed +0 -0
  58. auto_semver/setup/__init__.py +4 -0
  59. auto_semver/setup/check.py +93 -0
  60. auto_semver/setup/links.py +120 -0
  61. auto_semver/setup/scaffold.py +119 -0
  62. auto_semver/setup/scaffolds/__init__.py +3 -0
  63. auto_semver/setup/scaffolds/auto-semver.caller.yml +29 -0
  64. auto_semver/setup/scaffolds/auto_semver_config.yml +44 -0
  65. auto_semver/setup/scaffolds/promote.caller.yml +40 -0
  66. auto_semver/templates/__init__.py +26 -0
  67. auto_semver/templates/engine.py +595 -0
  68. auto_semver/templates/types.py +22 -0
  69. auto_semver/templates/utils.py +235 -0
  70. auto_semver_control-1.8.7.dev0.dist-info/METADATA +190 -0
  71. auto_semver_control-1.8.7.dev0.dist-info/RECORD +87 -0
  72. auto_semver_control-1.8.7.dev0.dist-info/WHEEL +5 -0
  73. auto_semver_control-1.8.7.dev0.dist-info/entry_points.txt +2 -0
  74. auto_semver_control-1.8.7.dev0.dist-info/licenses/LICENSE +674 -0
  75. auto_semver_control-1.8.7.dev0.dist-info/top_level.txt +2 -0
  76. runview/__init__.py +111 -0
  77. runview/bridge.py +43 -0
  78. runview/config.py +66 -0
  79. runview/file_sink.py +50 -0
  80. runview/record.py +42 -0
  81. runview/renderers/__init__.py +50 -0
  82. runview/renderers/base.py +76 -0
  83. runview/renderers/github.py +128 -0
  84. runview/renderers/plain.py +122 -0
  85. runview/renderers/rich_live.py +343 -0
  86. runview/reporter.py +178 -0
  87. runview/summary.py +102 -0
@@ -0,0 +1,212 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """
5
+ GitHub Actions context utilities.
6
+
7
+ This module provides helper classes and functions to extract relevant data
8
+ (branch name, commit SHA, etc.) from GitHub-provided event payloads.
9
+ It is typically used inside CI/CD pipelines triggered by pull request or push events.
10
+
11
+ Typical usage::
12
+
13
+ event = GitHubEvent()
14
+ branch_name = event.get_branch_name()
15
+ commit_sha = event.get_commit_sha()
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__package__)
24
+
25
+ _GITHUB_EVENT_PATH_ENV = "GITHUB_EVENT_PATH"
26
+ _PULL_REQUEST_KEY = "pull_request"
27
+
28
+
29
+ class GitHubEvent:
30
+ """
31
+ Encapsulates GitHub event data for convenient access to pull request information.
32
+
33
+ Loads and parses the event payload once during instantiation, and provides methods
34
+ to extract specific fields like branch name, commit SHA, and merge status.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ """Initialize the GitHubEvent by loading the event payload once."""
39
+ self._event_data = self._load_event_data()
40
+
41
+ def _load_event_data(self) -> dict[str, Any]:
42
+ """
43
+ Load GitHub event data from a JSON file defined by the environment variable.
44
+
45
+ Returns:
46
+ dict[str, Any]: Parsed JSON data.
47
+
48
+ Raises:
49
+ RuntimeError: If the environment variable is missing or the file can't be read.
50
+
51
+ """
52
+
53
+ event_path = os.getenv(_GITHUB_EVENT_PATH_ENV)
54
+ if not event_path:
55
+ logger.error(f"{_GITHUB_EVENT_PATH_ENV} is not set.")
56
+ raise RuntimeError(f"{_GITHUB_EVENT_PATH_ENV} is not set in environment.")
57
+
58
+ logger.debug(f"Reading event data from {event_path}")
59
+ try:
60
+ with open(event_path) as f:
61
+ return dict[str, Any](json.load(f))
62
+ except (OSError, json.JSONDecodeError) as err:
63
+ logger.error(f"Failed to load event data: {err}")
64
+ raise RuntimeError(f"Failed to load GitHub event data: {err}") from err
65
+
66
+ def _get(self, path: list[str]) -> Any:
67
+ """
68
+ Retrieve a nested value from the loaded event data.
69
+
70
+ Args:
71
+ path: A list of keys forming the path to the desired value.
72
+
73
+ Returns:
74
+ Any: The value at the given path.
75
+
76
+ Raises:
77
+ RuntimeError: If a key along the path is missing.
78
+
79
+ """
80
+
81
+ try:
82
+ data = self._event_data
83
+ for key in path:
84
+ data = data[key]
85
+ return data
86
+ except KeyError as err:
87
+ dotted_path = ".".join(path)
88
+ logger.error(f"Missing key in event data at path '{dotted_path}': {err}")
89
+ raise RuntimeError(f"Malformed event data: missing '{dotted_path}'") from err
90
+
91
+ def is_merged(self) -> bool:
92
+ """
93
+ Check whether the pull request was merged.
94
+
95
+ Returns:
96
+ bool: True if merged, False otherwise.
97
+
98
+ Raises:
99
+ RuntimeError: If the merged key is missing in the event data.
100
+
101
+ """
102
+
103
+ return bool(self._get([_PULL_REQUEST_KEY, "merged"]))
104
+
105
+ def get_source_branch_name(self) -> str:
106
+ """
107
+ Get the source branch name from the pull request event data.
108
+
109
+ Returns:
110
+ str: The name of the source branch.
111
+
112
+ Raises:
113
+ RuntimeError: If the branch name is missing in the event data.
114
+
115
+ """
116
+
117
+ return str(self._get([_PULL_REQUEST_KEY, "head", "ref"]))
118
+
119
+ def get_source_commit_sha(self) -> str:
120
+ """
121
+ Get the commit SHA of the source branch from the pull request event data.
122
+
123
+ Returns:
124
+ str: The commit SHA of the source branch.
125
+
126
+ Raises:
127
+ RuntimeError: If the sha is missing in the event data.
128
+
129
+ """
130
+
131
+ return str(self._get([_PULL_REQUEST_KEY, "head", "sha"]))
132
+
133
+ def get_target_branch_name(self) -> str:
134
+ """
135
+ Get the target branch name from the pull request event data.
136
+
137
+ Returns:
138
+ str: The name of the target branch.
139
+
140
+ Raises:
141
+ RuntimeError: If the branch name is missing in the event data.
142
+
143
+ """
144
+
145
+ return str(self._get([_PULL_REQUEST_KEY, "base", "ref"]))
146
+
147
+ def get_target_commit_sha(self) -> str:
148
+ """
149
+ Get the commit SHA of the target branch from the pull request event data.
150
+
151
+ Returns:
152
+ str: The commit SHA of the target branch.
153
+
154
+ Raises:
155
+ RuntimeError: If the sha is missing in the event data.
156
+
157
+ """
158
+
159
+ return str(self._get([_PULL_REQUEST_KEY, "base", "sha"]))
160
+
161
+ def get_merged_commit_sha(self) -> str:
162
+ """
163
+ Get the commit SHA of the merged pull request.
164
+
165
+ Returns:
166
+ str: The merge commit SHA.
167
+
168
+ Raises:
169
+ RuntimeError: If the pull request was not merged.
170
+ RuntimeError: If the merge commit SHA is missing in the event data.
171
+
172
+ """
173
+
174
+ if not self.is_merged():
175
+ logger.error("The pull request was closed but not merged.")
176
+ raise RuntimeError("The pull request was closed but not merged.")
177
+
178
+ return str(self._get([_PULL_REQUEST_KEY, "merge_commit_sha"]))
179
+
180
+ def get_body(self) -> str:
181
+ """Return the PR body."""
182
+ return str(self._get([_PULL_REQUEST_KEY, "body"]))
183
+
184
+ def get_title(self) -> str:
185
+ """Return the PR title."""
186
+ return str(self._get([_PULL_REQUEST_KEY, "title"]))
187
+
188
+ def get_labels(self) -> list[str]:
189
+ """Return the PR labels."""
190
+ labels: list[dict[str, Any]] = self._get([_PULL_REQUEST_KEY, "labels"])
191
+
192
+ if not labels:
193
+ raise ValueError("No labels found")
194
+
195
+ labels_str: list[str] = [label["name"] for label in labels]
196
+
197
+ return labels_str
198
+
199
+ def get_labels_safe(self) -> list[str]:
200
+ """
201
+ Return the PR labels safely.
202
+
203
+ If no labels are found return an empty list.
204
+ """
205
+ try:
206
+ return self.get_labels()
207
+ except ValueError:
208
+ return []
209
+
210
+ def get_repository(self) -> str:
211
+ """Return the repository full name."""
212
+ return str(self._get(["repository", "full_name"]))
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ from auto_semver.cli.main import main
5
+
6
+ __all__ = ["main"]
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """CLI main entry point for auto_semver."""
5
+
6
+ from auto_semver.cli.main import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,448 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """Version bump CLI operations for auto_semver."""
5
+
6
+ import datetime
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from auto_semver.adapters.git import GitOps
13
+ from auto_semver.adapters.github import GitHubEvent
14
+ from auto_semver.config import Config
15
+ from auto_semver.core.changelog.manager import ChangelogManager
16
+ from auto_semver.core.commits.grouper import CommitGrouper
17
+ from auto_semver.core.pr.github_builder import GitHubPRBuilder, GitHubPRTemplateVariables
18
+ from auto_semver.core.semver import Version
19
+ from auto_semver.core.semver.lock import SemverLock
20
+ from auto_semver.core.semver.updater import VersionFileUpdater
21
+ from auto_semver.core.semver.version import BumpCounts
22
+ from auto_semver.lock_sync.sync import sync_package_locks
23
+ from runview import get_summary, log_group, status
24
+
25
+ logger = logging.getLogger(__package__)
26
+
27
+
28
+ def _detect_tag_source_branch(*, version: Version, config: Config) -> str | None:
29
+ """
30
+ Detect which branch a version tag belongs to based on its suffix.
31
+
32
+ This function maps the version's suffix back to the source branch using
33
+ the suffixes configuration.
34
+
35
+ Args:
36
+ version: The version object with a suffix
37
+ config: The configuration containing suffix mappings
38
+
39
+ Returns:
40
+ The branch name that corresponds to the version's suffix, or None if not found
41
+ """
42
+ target_suffix = version.suffix or ""
43
+
44
+ for branch, suffix in config.data.suffixes.items():
45
+ if suffix == target_suffix:
46
+ return branch
47
+
48
+ return None
49
+
50
+
51
+ def _is_tag_promotion_scenario(*, version: Version, target_branch: str, config: Config) -> bool:
52
+ """
53
+ Check if the current scenario represents a tag promotion.
54
+
55
+ A tag promotion scenario occurs when:
56
+ 1. We have an existing version with a suffix that maps to a source branch
57
+ 2. There's a promotion rule from that source branch to the target branch
58
+ 3. This should result in suffix change only, no version bump
59
+
60
+ Args:
61
+ version: The current version object
62
+ target_branch: The target branch of the PR
63
+ config: The configuration containing promotion rules and suffixes
64
+
65
+ Returns:
66
+ True if this is a tag promotion scenario, False otherwise
67
+
68
+ Raises:
69
+ ValueError: If the version has a suffix that doesn't match any configured branch
70
+ """
71
+ source_branch = _detect_tag_source_branch(version=version, config=config)
72
+
73
+ if source_branch is None:
74
+ suffix_display = f"'{version.suffix}'" if version.suffix else "None (empty)"
75
+ error_msg = (
76
+ f"Version {version} has suffix {suffix_display} that doesn't match any "
77
+ f"configured branch suffix in: {list(config.data.suffixes.values())}"
78
+ )
79
+ logger.error(error_msg)
80
+ raise ValueError(error_msg)
81
+
82
+ for rule in config.data.promotions:
83
+ if rule.from_branch == source_branch and rule.to_branch == target_branch:
84
+ logger.info(f"Tag promotion detected: {version} from {source_branch} → {target_branch}")
85
+ return True
86
+
87
+ logger.debug(f"No promotion rule found from {source_branch} → {target_branch}")
88
+ return False
89
+
90
+
91
+ def _resolve_baseline_version(
92
+ *,
93
+ gitops: GitOps,
94
+ config: Config,
95
+ target_branch: str,
96
+ github_token: str,
97
+ ) -> Version:
98
+ """Resolve starting version from dev lock and open release PR (single mode)."""
99
+ gitops.fetch()
100
+ dev_version = gitops.get_lock_version_from_branch(target_branch)
101
+
102
+ if not dev_version:
103
+ try:
104
+ with open("version.txt") as f:
105
+ dev_version = Version.parse(f.read().strip())
106
+ except FileNotFoundError:
107
+ dev_version = config.data.start_version
108
+
109
+ baseline = dev_version
110
+ release_cfg = config.data.release
111
+
112
+ if release_cfg.strategy == "single":
113
+ open_release = gitops.get_open_release_version(
114
+ github_token=github_token,
115
+ target_branch=target_branch,
116
+ branch_prefix=release_cfg.branch_prefix,
117
+ labels=config.data.pull_request.labels,
118
+ )
119
+ if open_release and open_release > baseline:
120
+ logger.info(
121
+ "Using open release version %s as baseline (dev lock: %s)",
122
+ open_release,
123
+ baseline,
124
+ )
125
+ baseline = open_release
126
+
127
+ return Version(
128
+ major=baseline.major,
129
+ minor=baseline.minor,
130
+ patch=baseline.patch,
131
+ suffix=baseline.suffix,
132
+ )
133
+
134
+
135
+ def _apply_version_bump(
136
+ *,
137
+ version: Version,
138
+ config: Config,
139
+ current_branch: str,
140
+ gitops: GitOps,
141
+ target_branch: str,
142
+ baseline_sha: str | None,
143
+ github_token: str,
144
+ ) -> BumpCounts | None:
145
+ """Apply classic or cumulative bump rules."""
146
+ if config.data.bump.mode == "cumulative":
147
+ base_sha = baseline_sha or ""
148
+ if not base_sha:
149
+ logger.warning("No baseline SHA for cumulative bump; using current branch only")
150
+ branch_names = [current_branch]
151
+ else:
152
+ branch_names = gitops.get_merged_source_branches_since(
153
+ base_sha=base_sha,
154
+ target_branch=target_branch,
155
+ github_token=github_token,
156
+ )
157
+ if current_branch not in branch_names:
158
+ branch_names.append(current_branch)
159
+ return version.bump_cumulative(branch_names=branch_names)
160
+
161
+ version.bump(branch_name=current_branch)
162
+ return None
163
+
164
+
165
+ def _push_release_branch(
166
+ *,
167
+ gitops: GitOps,
168
+ release_branch_name: str,
169
+ force: bool = True,
170
+ ) -> None:
171
+ """Push release branch with one retry after re-fetch on rejection."""
172
+ try:
173
+ gitops.push(branch_name=release_branch_name, force=force)
174
+ except RuntimeError as err:
175
+ if "REJECTED" not in str(err).upper() and "rejected" not in str(err).lower():
176
+ raise
177
+ logger.warning("Push rejected; re-fetching and retrying once: %s", err)
178
+ gitops.fetch()
179
+ gitops.push(branch_name=release_branch_name, force=force)
180
+
181
+
182
+ def _supersede_old_releases_in_single_mode(
183
+ *,
184
+ gitops: GitOps,
185
+ config: Config,
186
+ github_token: str,
187
+ target_branch: str,
188
+ release_branch_name: str,
189
+ ) -> None:
190
+ """Close and optionally delete superseded release PRs/branches in single mode."""
191
+ release_cfg = config.data.release
192
+ logger.info("Closing old release PRs...")
193
+ gitops.close_old_release_prs(
194
+ github_token=github_token,
195
+ target_branch=target_branch,
196
+ labels=config.data.pull_request.labels,
197
+ branch_prefix=release_cfg.branch_prefix,
198
+ exclude_branch=release_branch_name,
199
+ delete_branches=release_cfg.cleanup_merged,
200
+ )
201
+ if release_cfg.cleanup_merged:
202
+ logger.info("Cleaning stale release branches...")
203
+ gitops.cleanup_stale_release_branches(
204
+ github_token=github_token,
205
+ target_branch=target_branch,
206
+ labels=config.data.pull_request.labels,
207
+ branch_prefix=release_cfg.branch_prefix,
208
+ exclude_branch=release_branch_name,
209
+ )
210
+
211
+
212
+ def run(
213
+ *,
214
+ gitops: GitOps,
215
+ event: GitHubEvent,
216
+ config: Config,
217
+ github_token: str,
218
+ ) -> None:
219
+ """
220
+ Run the bump workflow.
221
+
222
+ Args:
223
+ gitops (GitOps): GitOps object.
224
+ event (GitHubEvent): GitHubEvent object.
225
+ config (Config): Config object.
226
+ github_token (str): A token for github to generate a new PR.
227
+
228
+ """
229
+ changelog = ChangelogManager.from_config(config)
230
+ summary = get_summary()
231
+
232
+ current_branch: str = event.get_source_branch_name()
233
+ target_branch: str = event.get_target_branch_name()
234
+ release_cfg = config.data.release
235
+
236
+ repo_full_name: str = gitops.get_repository_name()
237
+ summary.set("branches", f"{current_branch} -> {target_branch}")
238
+
239
+ if target_branch not in config.data.suffixes:
240
+ logger.error(f"Target branch '{target_branch}' not found in suffixes configuration.")
241
+ raise ValueError(f"Target branch '{target_branch}' is not configured in suffixes.")
242
+
243
+ with log_group("Resolve version"):
244
+ logger.info(f"Branch name: {current_branch}")
245
+ with status("Fetching baseline..."):
246
+ gitops.fetch()
247
+ version = _resolve_baseline_version(
248
+ gitops=gitops,
249
+ config=config,
250
+ target_branch=target_branch,
251
+ github_token=github_token,
252
+ )
253
+ previous_version_str = str(version)
254
+
255
+ is_tag_promotion = _is_tag_promotion_scenario(
256
+ version=version, target_branch=target_branch, config=config
257
+ )
258
+
259
+ if is_tag_promotion:
260
+ logger.info(f"Detected tag promotion: {version} → {target_branch}")
261
+ else:
262
+ logger.info(f"Standard bump workflow: {current_branch} → {target_branch}")
263
+
264
+ logger.info(f"Current version: {version}")
265
+
266
+ bump_counts: BumpCounts | None = None
267
+
268
+ lockfile = SemverLock.get_or_create(
269
+ version=version,
270
+ source_branch=current_branch,
271
+ target_branch=target_branch,
272
+ )
273
+ baseline_sha = lockfile.target_base_sha
274
+
275
+ if not is_tag_promotion:
276
+ with status("Applying version bump..."):
277
+ bump_counts = _apply_version_bump(
278
+ version=version,
279
+ config=config,
280
+ current_branch=current_branch,
281
+ gitops=gitops,
282
+ target_branch=target_branch,
283
+ baseline_sha=baseline_sha,
284
+ github_token=github_token,
285
+ )
286
+ else:
287
+ logger.info("Skipping version bump for tag promotion - preserving version numbers")
288
+
289
+ suffix: str = config.data.suffixes[target_branch]
290
+ version.set_suffix(suffix=suffix)
291
+ new_version: str = str(version)
292
+ summary.set("version", f"{previous_version_str} -> {new_version}")
293
+ logger.info(f"New version: {new_version}")
294
+
295
+ with log_group("Update files/changelog"):
296
+ files_to_update: list[str] = config.data.version_files
297
+
298
+ with status("Updating version files and changelog..."):
299
+ for path in files_to_update:
300
+ VersionFileUpdater(file_path=path, version=version).update()
301
+
302
+ repo_root = Path(gitops.repo.working_tree_dir or ".")
303
+ release_branch_name = f"{release_cfg.branch_prefix.rstrip('/')}/{new_version}"
304
+
305
+ lockfile.version = version
306
+ lockfile.as_release_branch_lock()
307
+
308
+ try:
309
+ previous_lock_content = gitops.get_file_content_at_commit("HEAD~1", SemverLock.path)
310
+ if previous_lock_content:
311
+ previous_lock = SemverLock.from_dict(yaml.safe_load(previous_lock_content))
312
+ if previous_lock.target_base_sha != lockfile.target_base_sha:
313
+ logger.info(
314
+ "Baseline SHA changed in previous commit. "
315
+ "Detected potential Release PR match. Starting fresh changelog."
316
+ )
317
+ changelog.truncate = True
318
+ except Exception as e:
319
+ logger.warning(
320
+ f"Failed to compare lockfile with previous commit (HEAD~1). "
321
+ f"Skipping baseline check. Details: {e}"
322
+ )
323
+
324
+ latest_commit_sha = lockfile.target_base_sha or event.get_merged_commit_sha()
325
+
326
+ commit_messages = gitops.get_recent_commits(latest_commit_sha, config=config)
327
+ changelog.update(
328
+ version=new_version,
329
+ messages=commit_messages,
330
+ commit_groups=config.data.commit_groups.groups,
331
+ )
332
+
333
+ lockfile.target_base_sha = event.get_merged_commit_sha()
334
+ lockfile.save_to_file()
335
+
336
+ author = event.get_actor() if hasattr(event, "get_actor") else "auto-semver"
337
+ feature_count = bump_counts.feature_count if bump_counts else 0
338
+ fix_count = bump_counts.fix_count if bump_counts else 0
339
+ synced_locks = sync_package_locks(repo_root=repo_root, config=config.data.lock_sync)
340
+
341
+ _publish_release(
342
+ gitops=gitops,
343
+ config=config,
344
+ github_token=github_token,
345
+ new_version=new_version,
346
+ previous_version=previous_version_str,
347
+ release_branch_name=release_branch_name,
348
+ target_branch=target_branch,
349
+ files_to_update=list(files_to_update),
350
+ synced_locks=synced_locks,
351
+ changelog_path=str(changelog.path),
352
+ semver_lock_path=str(lockfile.path),
353
+ commit_messages=list(commit_messages),
354
+ feature_count=feature_count,
355
+ fix_count=fix_count,
356
+ author=author,
357
+ repository=repo_full_name,
358
+ release_strategy=release_cfg.strategy,
359
+ )
360
+
361
+
362
+ def _publish_release(
363
+ *,
364
+ gitops: GitOps,
365
+ config: Config,
366
+ github_token: str,
367
+ new_version: str,
368
+ previous_version: str,
369
+ release_branch_name: str,
370
+ target_branch: str,
371
+ files_to_update: list[str],
372
+ synced_locks: list[str],
373
+ changelog_path: str,
374
+ semver_lock_path: str,
375
+ commit_messages: list[str],
376
+ feature_count: int,
377
+ fix_count: int,
378
+ author: str,
379
+ repository: str,
380
+ release_strategy: str,
381
+ ) -> None:
382
+ """Create the release branch, commit, push, and open the pull request."""
383
+ summary = get_summary()
384
+ with log_group("Git commit/push"):
385
+ with status("Creating release branch and pushing..."):
386
+ gitops.create_branch(branch_name=release_branch_name, force=True)
387
+ gitops.add([*files_to_update, *synced_locks])
388
+ gitops.add([semver_lock_path])
389
+ gitops.add([changelog_path])
390
+ gitops.commit(f"Release {new_version}", force=True)
391
+ _push_release_branch(gitops=gitops, release_branch_name=release_branch_name)
392
+
393
+ if release_strategy == "single":
394
+ _supersede_old_releases_in_single_mode(
395
+ gitops=gitops,
396
+ config=config,
397
+ github_token=github_token,
398
+ target_branch=target_branch,
399
+ release_branch_name=release_branch_name,
400
+ )
401
+ else:
402
+ logger.info("release.strategy=multi — keeping existing open release PRs")
403
+
404
+ with log_group("Open PR"):
405
+ release_date = datetime.date.today().strftime("%d-%m-%Y")
406
+
407
+ commit_groups_data = None
408
+ if config.data.commit_groups.groups:
409
+ commit_groups_data = CommitGrouper.group_messages(
410
+ commit_messages, config.data.commit_groups
411
+ )
412
+
413
+ pr_variables = GitHubPRTemplateVariables(
414
+ version=new_version,
415
+ previous_version=previous_version,
416
+ commit_groups=commit_groups_data or [],
417
+ breaking_changes=[],
418
+ author=author,
419
+ repository=repository,
420
+ date=release_date,
421
+ branch=release_branch_name,
422
+ base_branch=target_branch,
423
+ labels=config.data.pull_request.labels,
424
+ groups=commit_groups_data,
425
+ feature_count=feature_count,
426
+ fix_count=fix_count,
427
+ )
428
+
429
+ pr_builder = GitHubPRBuilder(
430
+ data=pr_variables,
431
+ title_template=config.data.pull_request.title,
432
+ body_template=config.data.pull_request.body,
433
+ labels_template=",".join(config.data.pull_request.labels or []),
434
+ )
435
+
436
+ pr_title: str = pr_builder.title
437
+ pr_body: str = pr_builder.body
438
+
439
+ with status("Opening pull request..."):
440
+ pr_number = gitops.create_pr(
441
+ title=pr_title,
442
+ body=pr_body,
443
+ source=release_branch_name,
444
+ target=target_branch,
445
+ github_token=github_token,
446
+ labels=config.data.pull_request.labels,
447
+ )
448
+ summary.set("pr", f"#{pr_number}")