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.
- auto_semver/__init__.py +5 -0
- auto_semver/adapters/__init__.py +4 -0
- auto_semver/adapters/git/__init__.py +6 -0
- auto_semver/adapters/git/base.py +22 -0
- auto_semver/adapters/git/constants.py +27 -0
- auto_semver/adapters/git/local.py +450 -0
- auto_semver/adapters/git/ops.py +17 -0
- auto_semver/adapters/git/promote.py +668 -0
- auto_semver/adapters/git/release_pr.py +668 -0
- auto_semver/adapters/git/verified.py +491 -0
- auto_semver/adapters/github/__init__.py +6 -0
- auto_semver/adapters/github/event.py +212 -0
- auto_semver/cli/__init__.py +6 -0
- auto_semver/cli/__main__.py +9 -0
- auto_semver/cli/bump.py +448 -0
- auto_semver/cli/finalize.py +223 -0
- auto_semver/cli/main.py +281 -0
- auto_semver/cli/promote.py +211 -0
- auto_semver/cli/setup.py +154 -0
- auto_semver/cli/utils.py +161 -0
- auto_semver/config/__init__.py +48 -0
- auto_semver/config/_models/__init__.py +35 -0
- auto_semver/config/_models/bump.py +22 -0
- auto_semver/config/_models/changelog.py +104 -0
- auto_semver/config/_models/commit_group.py +160 -0
- auto_semver/config/_models/commit_groups.py +63 -0
- auto_semver/config/_models/config.py +247 -0
- auto_semver/config/_models/lock_sync.py +48 -0
- auto_semver/config/_models/promotion.py +72 -0
- auto_semver/config/_models/pull_request.py +186 -0
- auto_semver/config/_models/release.py +31 -0
- auto_semver/config/config.py +128 -0
- auto_semver/config/constants.py +16 -0
- auto_semver/core/__init__.py +4 -0
- auto_semver/core/changelog/__init__.py +6 -0
- auto_semver/core/changelog/manager.py +289 -0
- auto_semver/core/commits/__init__.py +7 -0
- auto_semver/core/commits/grouper.py +276 -0
- auto_semver/core/commits/parser.py +164 -0
- auto_semver/core/pr/__init__.py +12 -0
- auto_semver/core/pr/builder.py +92 -0
- auto_semver/core/pr/github_builder.py +86 -0
- auto_semver/core/semver/__init__.py +8 -0
- auto_semver/core/semver/lock.py +200 -0
- auto_semver/core/semver/updater.py +75 -0
- auto_semver/core/semver/version.py +466 -0
- auto_semver/lock_sync/__init__.py +40 -0
- auto_semver/lock_sync/patch.py +206 -0
- auto_semver/lock_sync/pep440.py +56 -0
- auto_semver/lock_sync/registry.py +78 -0
- auto_semver/lock_sync/runner.py +100 -0
- auto_semver/lock_sync/strategies/__init__.py +17 -0
- auto_semver/lock_sync/strategies/npm.py +41 -0
- auto_semver/lock_sync/strategies/uv.py +41 -0
- auto_semver/lock_sync/strategy.py +59 -0
- auto_semver/lock_sync/sync.py +178 -0
- auto_semver/py.typed +0 -0
- auto_semver/setup/__init__.py +4 -0
- auto_semver/setup/check.py +93 -0
- auto_semver/setup/links.py +120 -0
- auto_semver/setup/scaffold.py +119 -0
- auto_semver/setup/scaffolds/__init__.py +3 -0
- auto_semver/setup/scaffolds/auto-semver.caller.yml +29 -0
- auto_semver/setup/scaffolds/auto_semver_config.yml +44 -0
- auto_semver/setup/scaffolds/promote.caller.yml +40 -0
- auto_semver/templates/__init__.py +26 -0
- auto_semver/templates/engine.py +595 -0
- auto_semver/templates/types.py +22 -0
- auto_semver/templates/utils.py +235 -0
- auto_semver_control-1.8.7.dev0.dist-info/METADATA +190 -0
- auto_semver_control-1.8.7.dev0.dist-info/RECORD +87 -0
- auto_semver_control-1.8.7.dev0.dist-info/WHEEL +5 -0
- auto_semver_control-1.8.7.dev0.dist-info/entry_points.txt +2 -0
- auto_semver_control-1.8.7.dev0.dist-info/licenses/LICENSE +674 -0
- auto_semver_control-1.8.7.dev0.dist-info/top_level.txt +2 -0
- runview/__init__.py +111 -0
- runview/bridge.py +43 -0
- runview/config.py +66 -0
- runview/file_sink.py +50 -0
- runview/record.py +42 -0
- runview/renderers/__init__.py +50 -0
- runview/renderers/base.py +76 -0
- runview/renderers/github.py +128 -0
- runview/renderers/plain.py +122 -0
- runview/renderers/rich_live.py +343 -0
- runview/reporter.py +178 -0
- runview/summary.py +102 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Promotion orchestration."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable, Collection
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from git import GitCommandError
|
|
15
|
+
from git.remote import Remote
|
|
16
|
+
from github.GithubException import GithubException
|
|
17
|
+
|
|
18
|
+
from auto_semver.adapters.git.base import GitOpsBase
|
|
19
|
+
from auto_semver.config.constants import (
|
|
20
|
+
VERSION_METADATA_COMMIT,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__package__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GitPromote(GitOpsBase):
|
|
30
|
+
"""Promotion orchestration."""
|
|
31
|
+
|
|
32
|
+
def merge(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
source_ref: str,
|
|
36
|
+
message: str,
|
|
37
|
+
no_ff: bool = True,
|
|
38
|
+
remote_name: str = "origin",
|
|
39
|
+
is_tag: bool = False,
|
|
40
|
+
prefer_source_paths: Collection[str] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Merge a source ref into the current branch.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
source_ref (str): Source reference to merge (branch name, will use remote/branch).
|
|
47
|
+
message (str): Merge commit message.
|
|
48
|
+
no_ff (bool): If True, create a merge commit even if fast-forward is possible.
|
|
49
|
+
remote_name (str): Remote name to prefix to source_ref (default: 'origin').
|
|
50
|
+
is_tag (bool): If True, treat source_ref as a tag (do not prepend remote).
|
|
51
|
+
prefer_source_paths: Relative paths where conflicts may be auto-resolved by
|
|
52
|
+
taking the source (theirs) side. Used during promotion for version
|
|
53
|
+
metadata files that diverge by design between branches.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If merge fails due to conflicts or other errors.
|
|
57
|
+
"""
|
|
58
|
+
if is_tag:
|
|
59
|
+
full_source_ref = source_ref
|
|
60
|
+
else:
|
|
61
|
+
full_source_ref = f"{remote_name}/{source_ref}"
|
|
62
|
+
|
|
63
|
+
logger.info(f"Merging '{full_source_ref}' into current branch (no-ff={no_ff})")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
self.repo.git.merge(full_source_ref, no_ff=no_ff, m=message)
|
|
67
|
+
logger.info(f"Merge successful: {full_source_ref} → HEAD")
|
|
68
|
+
except GitCommandError as merge_err:
|
|
69
|
+
stderr = str(merge_err)
|
|
70
|
+
if "CONFLICT" in stderr or "conflict" in stderr.lower():
|
|
71
|
+
if prefer_source_paths and self._resolve_promotion_conflicts(
|
|
72
|
+
prefer_source_paths=prefer_source_paths,
|
|
73
|
+
merge_message=message,
|
|
74
|
+
):
|
|
75
|
+
logger.info(
|
|
76
|
+
"Resolved promotion-file conflicts preferring source; merge completed"
|
|
77
|
+
)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
logger.error(f"Merge conflict detected: {merge_err}")
|
|
81
|
+
# Attempt to abort the merge to keep repo clean
|
|
82
|
+
try:
|
|
83
|
+
self.repo.git.merge("--abort")
|
|
84
|
+
logger.debug("Aborted merge after conflict")
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.warning("Failed to abort merge after conflict")
|
|
87
|
+
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"Merge conflict detected when merging '{full_source_ref}'. "
|
|
90
|
+
"Please resolve conflicts manually."
|
|
91
|
+
) from merge_err
|
|
92
|
+
|
|
93
|
+
logger.error(f"Merge failed: {merge_err}")
|
|
94
|
+
raise RuntimeError(f"Merge failed: {merge_err}") from merge_err
|
|
95
|
+
|
|
96
|
+
def _unmerged_paths(self) -> list[str]:
|
|
97
|
+
"""Return paths with unresolved merge conflicts."""
|
|
98
|
+
raw = self.repo.git.diff(name_only=True, diff_filter="U")
|
|
99
|
+
if not raw:
|
|
100
|
+
return []
|
|
101
|
+
return [line.strip() for line in raw.splitlines() if line.strip()]
|
|
102
|
+
|
|
103
|
+
def _resolve_promotion_conflicts(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
prefer_source_paths: Collection[str],
|
|
107
|
+
merge_message: str,
|
|
108
|
+
) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Resolve conflicts by taking the source (theirs) side for allowlisted paths.
|
|
111
|
+
|
|
112
|
+
Returns True when every conflicted path was allowlisted and the merge was
|
|
113
|
+
completed; False when unresolved or non-allowlisted conflicts remain.
|
|
114
|
+
"""
|
|
115
|
+
allowed = {str(Path(p)).replace("\\", "/") for p in prefer_source_paths}
|
|
116
|
+
conflicted = self._unmerged_paths()
|
|
117
|
+
if not conflicted:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
normalized = [str(Path(p)).replace("\\", "/") for p in conflicted]
|
|
121
|
+
disallowed = [p for p in normalized if p not in allowed]
|
|
122
|
+
if disallowed:
|
|
123
|
+
logger.error(
|
|
124
|
+
"Cannot auto-resolve merge conflicts outside promotion files: %s",
|
|
125
|
+
", ".join(disallowed),
|
|
126
|
+
)
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
for path in conflicted:
|
|
130
|
+
logger.info("Preferring source version for conflicted file: %s", path)
|
|
131
|
+
self.repo.git.checkout("--theirs", "--", path)
|
|
132
|
+
self.repo.git.add("--", path)
|
|
133
|
+
|
|
134
|
+
remaining = self._unmerged_paths()
|
|
135
|
+
if remaining:
|
|
136
|
+
logger.error("Unresolved conflicts remain after prefer-source: %s", remaining)
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# Complete the in-progress merge without re-invoking git merge.
|
|
140
|
+
self.repo.git.commit(m=merge_message, no_edit=False)
|
|
141
|
+
logger.info("Completed merge after resolving promotion-file conflicts")
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def _resolve_ref_sha(self, ref: str) -> str:
|
|
145
|
+
"""Resolve a branch or tag name to a commit SHA."""
|
|
146
|
+
try:
|
|
147
|
+
return str(self.repo.commit(ref).hexsha)
|
|
148
|
+
except GitCommandError as err:
|
|
149
|
+
raise RuntimeError(f"Could not resolve ref '{ref}': {err}") from err
|
|
150
|
+
|
|
151
|
+
def _squash_commit_from_ref(self, *, ref: str, message: str) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Create a single promotion commit whose tree matches ``ref``.
|
|
154
|
+
|
|
155
|
+
Uses the current branch tip as the sole parent so protected branches that
|
|
156
|
+
reject merge commits still receive a linear promote commit.
|
|
157
|
+
"""
|
|
158
|
+
source_commit = self.repo.commit(ref)
|
|
159
|
+
parent = self.repo.head.commit
|
|
160
|
+
new_sha = str(
|
|
161
|
+
self.repo.git.commit_tree(
|
|
162
|
+
source_commit.tree.hexsha,
|
|
163
|
+
"-p",
|
|
164
|
+
parent.hexsha,
|
|
165
|
+
"-m",
|
|
166
|
+
message,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
self.repo.head.set_commit(self.repo.commit(new_sha))
|
|
170
|
+
# HEAD moved but index/worktree still reflect pre-promotion target; sync before hooks.
|
|
171
|
+
self.repo.git.reset("--hard", new_sha)
|
|
172
|
+
logger.info("Created squash promotion commit %s from %s", new_sha[:8], ref)
|
|
173
|
+
return new_sha
|
|
174
|
+
|
|
175
|
+
def _integrate_source_for_promotion(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
source_ref: str,
|
|
179
|
+
message: str,
|
|
180
|
+
remote_name: str,
|
|
181
|
+
is_tag: bool,
|
|
182
|
+
prefer_source_paths: Collection[str] | None,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Integrate a promotion source into the current branch.
|
|
186
|
+
|
|
187
|
+
Tag sources try fast-forward first, then fall back to a single squash commit
|
|
188
|
+
(no merge commit). Branch sources merge with ff when possible and auto-resolve
|
|
189
|
+
metadata conflicts by preferring the source side.
|
|
190
|
+
"""
|
|
191
|
+
full_ref = source_ref if is_tag else f"{remote_name}/{source_ref}"
|
|
192
|
+
|
|
193
|
+
if is_tag:
|
|
194
|
+
try:
|
|
195
|
+
self.repo.git.merge(full_ref, ff_only=True)
|
|
196
|
+
logger.info("Fast-forward promotion merge successful: %s", full_ref)
|
|
197
|
+
return
|
|
198
|
+
except GitCommandError as err:
|
|
199
|
+
stderr = str(err)
|
|
200
|
+
if "CONFLICT" in stderr or "conflict" in stderr.lower():
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
f"Fast-forward promotion conflict for '{full_ref}'. "
|
|
203
|
+
"Resolve manually before promoting."
|
|
204
|
+
) from err
|
|
205
|
+
logger.info(
|
|
206
|
+
"Fast-forward not possible for %s; creating squash promotion commit",
|
|
207
|
+
full_ref,
|
|
208
|
+
)
|
|
209
|
+
self._squash_commit_from_ref(ref=full_ref, message=message)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
self.merge(
|
|
213
|
+
source_ref=source_ref,
|
|
214
|
+
message=message,
|
|
215
|
+
remote_name=remote_name,
|
|
216
|
+
is_tag=False,
|
|
217
|
+
prefer_source_paths=prefer_source_paths,
|
|
218
|
+
no_ff=False,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _api_squash_promote(self, *, base: str, head: str, message: str) -> str:
|
|
222
|
+
"""
|
|
223
|
+
Create a single verified commit on ``base`` whose tree matches ``head``.
|
|
224
|
+
|
|
225
|
+
Uses GraphQL ``createCommitOnBranch`` with a two-dot ``git diff``
|
|
226
|
+
(``--no-renames``) between the branch tip and ``head`` so deletions and
|
|
227
|
+
rename old-paths are included. GitHub ``compare()`` is three-dot and
|
|
228
|
+
capped at 300 files, so it cannot be used for tree sync.
|
|
229
|
+
"""
|
|
230
|
+
gh_repo = self._gh_repo()
|
|
231
|
+
base_ref = gh_repo.get_git_ref(f"heads/{base}")
|
|
232
|
+
base_sha = str(base_ref.object.sha)
|
|
233
|
+
head_sha = self._resolve_ref_sha(head)
|
|
234
|
+
|
|
235
|
+
addition_paths, deletion_paths = self._diff_paths_between(
|
|
236
|
+
base_sha=base_sha, head_ref=head_sha
|
|
237
|
+
)
|
|
238
|
+
additions: list[dict[str, str]] = []
|
|
239
|
+
deletions: list[dict[str, str]] = [
|
|
240
|
+
{"path": self._normalize_repo_rel_path(path)} for path in deletion_paths
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
for rel_path in addition_paths:
|
|
244
|
+
path = self._normalize_repo_rel_path(rel_path)
|
|
245
|
+
content_file = gh_repo.get_contents(path, ref=head_sha)
|
|
246
|
+
if isinstance(content_file, list):
|
|
247
|
+
raise RuntimeError(f"Expected a file at {path}@{head_sha}, got a directory listing")
|
|
248
|
+
raw = bytes(content_file.decoded_content)
|
|
249
|
+
additions.append(
|
|
250
|
+
{
|
|
251
|
+
"path": path,
|
|
252
|
+
"contents": base64.b64encode(raw).decode("ascii"),
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if not additions and not deletions:
|
|
257
|
+
logger.info(
|
|
258
|
+
"Squash promote %s -> %s has no file diff; leaving %s at %s",
|
|
259
|
+
head,
|
|
260
|
+
base,
|
|
261
|
+
base,
|
|
262
|
+
base_sha,
|
|
263
|
+
)
|
|
264
|
+
return base_sha
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
commit_sha = self._graphql_create_commit_on_branch(
|
|
268
|
+
branch_name=base,
|
|
269
|
+
message=message,
|
|
270
|
+
expected_head_oid=base_sha,
|
|
271
|
+
additions=additions,
|
|
272
|
+
deletions=deletions,
|
|
273
|
+
)
|
|
274
|
+
except GithubException as err:
|
|
275
|
+
if not self._is_expected_head_mismatch(err):
|
|
276
|
+
raise
|
|
277
|
+
logger.warning(
|
|
278
|
+
"expectedHeadOid mismatch on squash promote to %s; retrying once: %s",
|
|
279
|
+
base,
|
|
280
|
+
err,
|
|
281
|
+
)
|
|
282
|
+
self.fetch()
|
|
283
|
+
base_ref = gh_repo.get_git_ref(f"heads/{base}")
|
|
284
|
+
base_sha = str(base_ref.object.sha)
|
|
285
|
+
commit_sha = self._graphql_create_commit_on_branch(
|
|
286
|
+
branch_name=base,
|
|
287
|
+
message=message,
|
|
288
|
+
expected_head_oid=base_sha,
|
|
289
|
+
additions=additions,
|
|
290
|
+
deletions=deletions,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
logger.info("Verified squash promotion commit on %s (%s)", base, commit_sha)
|
|
294
|
+
return commit_sha
|
|
295
|
+
|
|
296
|
+
def _integrate_source_for_promotion_api(
|
|
297
|
+
self,
|
|
298
|
+
*,
|
|
299
|
+
target_branch: str,
|
|
300
|
+
source_ref: str,
|
|
301
|
+
message: str,
|
|
302
|
+
is_source_tag: bool,
|
|
303
|
+
) -> str:
|
|
304
|
+
"""Integrate promotion source via GitHub API (ff merge or squash commit)."""
|
|
305
|
+
try:
|
|
306
|
+
return self._api_merge(base=target_branch, head=source_ref, message=message)
|
|
307
|
+
except (GithubException, RuntimeError) as err:
|
|
308
|
+
if not is_source_tag:
|
|
309
|
+
raise
|
|
310
|
+
logger.info(
|
|
311
|
+
"API merge failed for tag promotion (%s); using squash commit: %s",
|
|
312
|
+
source_ref,
|
|
313
|
+
err,
|
|
314
|
+
)
|
|
315
|
+
return self._api_squash_promote(base=target_branch, head=source_ref, message=message)
|
|
316
|
+
|
|
317
|
+
def auto_promote(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
source_branch: str,
|
|
321
|
+
target_branch: str,
|
|
322
|
+
version: str,
|
|
323
|
+
source_version: str | None = None,
|
|
324
|
+
remote_name: str = "origin",
|
|
325
|
+
is_source_tag: bool = False,
|
|
326
|
+
post_merge_hook: Callable[[str, str], None] | None = None,
|
|
327
|
+
prefer_source_paths: Collection[str] | None = None,
|
|
328
|
+
) -> str:
|
|
329
|
+
"""
|
|
330
|
+
Automatically promote changes from source branch to target branch.
|
|
331
|
+
|
|
332
|
+
This performs a local merge operation (SCM-agnostic) that:
|
|
333
|
+
1. Fetches latest changes from remote
|
|
334
|
+
2. Checks out/creates the target branch
|
|
335
|
+
3. Pulls latest changes on target
|
|
336
|
+
4. Merges source branch into target
|
|
337
|
+
5. Creates a tag on target
|
|
338
|
+
6. Pushes target branch and tags to remote
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
source_branch (str): Source branch name (e.g., 'dev') or tag name.
|
|
342
|
+
target_branch (str): Target branch name (e.g., 'staging').
|
|
343
|
+
version (str): Version tag to create on the target branch.
|
|
344
|
+
source_version (str | None): Original version tag from source branch.
|
|
345
|
+
remote_name (str): Remote name (default: 'origin').
|
|
346
|
+
is_source_tag (bool): If True, treat source_branch as a tag.
|
|
347
|
+
post_merge_hook (Callable[[str, str], None] | None): Optional hook function to execute after merge
|
|
348
|
+
but before commit/tag.
|
|
349
|
+
Receives (source_version_str, target_version_str).
|
|
350
|
+
prefer_source_paths: Paths to auto-resolve favoring the source branch on
|
|
351
|
+
conflict (changelog, lockfile, version files).
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
str: The version tag that was created.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
RuntimeError: If any operation fails (fetch, merge, push, etc.).
|
|
358
|
+
"""
|
|
359
|
+
logger.info(f"Starting auto-promotion: {source_branch} → {target_branch}")
|
|
360
|
+
|
|
361
|
+
if source_version:
|
|
362
|
+
merge_message = (
|
|
363
|
+
f"chore: auto-promote {source_version} from {source_branch} "
|
|
364
|
+
f"to {target_branch} as {version}"
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
merge_message = (
|
|
368
|
+
f"chore: auto-promote from {source_branch} to {target_branch} as {version}"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if self.signed_commits:
|
|
372
|
+
return self._auto_promote_api(
|
|
373
|
+
source_branch=source_branch,
|
|
374
|
+
target_branch=target_branch,
|
|
375
|
+
version=version,
|
|
376
|
+
source_version=source_version,
|
|
377
|
+
merge_message=merge_message,
|
|
378
|
+
is_source_tag=is_source_tag,
|
|
379
|
+
post_merge_hook=post_merge_hook,
|
|
380
|
+
prefer_source_paths=prefer_source_paths,
|
|
381
|
+
remote_name=remote_name,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
# 1. Fetch latest changes
|
|
386
|
+
self.fetch(remote_name=remote_name)
|
|
387
|
+
|
|
388
|
+
# 2. Checkout or create target branch
|
|
389
|
+
if target_branch in self.repo.heads:
|
|
390
|
+
self.checkout(branch_name=target_branch)
|
|
391
|
+
else:
|
|
392
|
+
self.checkout(
|
|
393
|
+
branch_name=target_branch, create_from=f"{remote_name}/{target_branch}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# 3. Pull latest changes on target
|
|
397
|
+
self.pull(branch_name=target_branch, remote_name=remote_name)
|
|
398
|
+
|
|
399
|
+
# 4. Integrate source into target (ff, squash, or merge + metadata wins)
|
|
400
|
+
self._integrate_source_for_promotion(
|
|
401
|
+
source_ref=source_branch,
|
|
402
|
+
message=merge_message,
|
|
403
|
+
remote_name=remote_name,
|
|
404
|
+
is_tag=is_source_tag,
|
|
405
|
+
prefer_source_paths=prefer_source_paths,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Executing post-merge hook if provided
|
|
409
|
+
if post_merge_hook:
|
|
410
|
+
logger.info("Executing post-merge hook")
|
|
411
|
+
# Fallback to source_branch if version not explicit
|
|
412
|
+
src_v = source_version if source_version else source_branch
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
post_merge_hook(src_v, version)
|
|
416
|
+
dirty_paths = self._collect_dirty_tracked_paths()
|
|
417
|
+
if dirty_paths:
|
|
418
|
+
logger.info(
|
|
419
|
+
"Changes detected after post-merge hook; committing metadata: %s",
|
|
420
|
+
", ".join(dirty_paths),
|
|
421
|
+
)
|
|
422
|
+
for path in dirty_paths:
|
|
423
|
+
self.repo.git.add("--", path)
|
|
424
|
+
self._local_commit(VERSION_METADATA_COMMIT.format(version=version))
|
|
425
|
+
except Exception as e:
|
|
426
|
+
logger.error(f"Post-merge hook failed: {e}")
|
|
427
|
+
raise RuntimeError(f"Post-merge hook failed: {e}") from e
|
|
428
|
+
|
|
429
|
+
# 5. Create tag on target branch
|
|
430
|
+
logger.info(f"Creating tag '{version}' on '{target_branch}'")
|
|
431
|
+
tag_ref = self.repo.create_tag(version, message=f"Auto-promotion: {version}")
|
|
432
|
+
|
|
433
|
+
# 6. Push target branch
|
|
434
|
+
self.push(branch_name=target_branch, remote_name=remote_name)
|
|
435
|
+
|
|
436
|
+
# 7. Push tags
|
|
437
|
+
logger.info("Pushing tags to remote")
|
|
438
|
+
remote: Remote = self.repo.remote(name=remote_name)
|
|
439
|
+
remote.push(tags=True)
|
|
440
|
+
|
|
441
|
+
logger.info(
|
|
442
|
+
f"✅ Auto-promotion complete: {source_branch} → {target_branch} (tagged: {version})"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return str(tag_ref)
|
|
446
|
+
|
|
447
|
+
except (GitCommandError, RuntimeError) as err:
|
|
448
|
+
logger.error(f"Auto-promotion failed: {err}")
|
|
449
|
+
raise RuntimeError(f"Auto-promotion failed: {err}") from err
|
|
450
|
+
except Exception as err:
|
|
451
|
+
logger.error(f"Unexpected error during auto-promotion: {err}")
|
|
452
|
+
raise RuntimeError(f"Auto-promotion failed unexpectedly: {err}") from err
|
|
453
|
+
|
|
454
|
+
def _diff_paths_between(
|
|
455
|
+
self, *, base_sha: str, head_ref: str = "HEAD"
|
|
456
|
+
) -> tuple[list[str], list[str]]:
|
|
457
|
+
"""
|
|
458
|
+
Return (additions/modifications, deletions) between two commits.
|
|
459
|
+
|
|
460
|
+
``--no-renames`` so overlay publishes delete the old path of a move.
|
|
461
|
+
"""
|
|
462
|
+
added = self.repo.git.diff(
|
|
463
|
+
base_sha, head_ref, "--no-renames", "--name-only", "--diff-filter=ACMR"
|
|
464
|
+
)
|
|
465
|
+
deleted = self.repo.git.diff(
|
|
466
|
+
base_sha, head_ref, "--no-renames", "--name-only", "--diff-filter=D"
|
|
467
|
+
)
|
|
468
|
+
additions = [line.strip() for line in added.splitlines() if line.strip()]
|
|
469
|
+
deletions = [line.strip() for line in deleted.splitlines() if line.strip()]
|
|
470
|
+
return additions, deletions
|
|
471
|
+
|
|
472
|
+
def _remote_has_commit(self, sha: str) -> bool:
|
|
473
|
+
"""Return True when ``sha`` is already present on the GitHub remote."""
|
|
474
|
+
try:
|
|
475
|
+
self._gh_repo().get_git_commit(sha)
|
|
476
|
+
except GithubException:
|
|
477
|
+
return False
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
def _publish_local_tip_once(
|
|
481
|
+
self,
|
|
482
|
+
*,
|
|
483
|
+
branch_name: str,
|
|
484
|
+
base_sha: str,
|
|
485
|
+
message: str,
|
|
486
|
+
) -> str:
|
|
487
|
+
"""
|
|
488
|
+
Move ``branch_name`` to the local tip with a single remote ref update.
|
|
489
|
+
|
|
490
|
+
Fast-forwards the branch when HEAD already exists on the remote and
|
|
491
|
+
matches ``base_sha``'s descendant with no unpublished local-only history
|
|
492
|
+
that needs signing. Otherwise publishes the final worktree as one
|
|
493
|
+
verified ``createCommitOnBranch`` or REST Git Data commit so promote +
|
|
494
|
+
metadata never produce two push events on the integration branch. REST
|
|
495
|
+
is used when the tip needs executable/symlink modes GraphQL cannot
|
|
496
|
+
express.
|
|
497
|
+
"""
|
|
498
|
+
local_tip = self.repo.head.commit.hexsha
|
|
499
|
+
if local_tip == base_sha:
|
|
500
|
+
logger.info("No promotion changes on %s; leaving branch at %s", branch_name, base_sha)
|
|
501
|
+
return base_sha
|
|
502
|
+
|
|
503
|
+
additions, deletions = self._diff_paths_between(base_sha=base_sha, head_ref=local_tip)
|
|
504
|
+
if not additions and not deletions:
|
|
505
|
+
if self._remote_has_commit(local_tip):
|
|
506
|
+
ref = self._gh_repo().get_git_ref(f"heads/{branch_name}")
|
|
507
|
+
ref.edit(sha=local_tip)
|
|
508
|
+
self.fetch()
|
|
509
|
+
logger.info(
|
|
510
|
+
"Fast-forwarded %s to existing commit %s (single ref update)",
|
|
511
|
+
branch_name,
|
|
512
|
+
local_tip,
|
|
513
|
+
)
|
|
514
|
+
return local_tip
|
|
515
|
+
logger.info(
|
|
516
|
+
"Empty tree diff for %s but tip %s is local-only; leaving at %s",
|
|
517
|
+
branch_name,
|
|
518
|
+
local_tip,
|
|
519
|
+
base_sha,
|
|
520
|
+
)
|
|
521
|
+
return base_sha
|
|
522
|
+
|
|
523
|
+
# Prefer a true fast-forward when the tip is already on the remote and
|
|
524
|
+
# there are no local-only commits beyond that tip (e.g. tag promote ff).
|
|
525
|
+
if self._remote_has_commit(local_tip):
|
|
526
|
+
try:
|
|
527
|
+
self.repo.git.merge_base("--is-ancestor", base_sha, local_tip)
|
|
528
|
+
ref = self._gh_repo().get_git_ref(f"heads/{branch_name}")
|
|
529
|
+
ref.edit(sha=local_tip)
|
|
530
|
+
self.fetch()
|
|
531
|
+
logger.info(
|
|
532
|
+
"Fast-forwarded %s to %s (single ref update)",
|
|
533
|
+
branch_name,
|
|
534
|
+
local_tip,
|
|
535
|
+
)
|
|
536
|
+
return local_tip
|
|
537
|
+
except GitCommandError:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
repo_root = Path(self.repo.working_tree_dir or ".")
|
|
541
|
+
use_rest = self._tree_diff_requires_rest(base_sha=base_sha, head_ref=local_tip)
|
|
542
|
+
|
|
543
|
+
def _publish_tip(expected: str) -> str:
|
|
544
|
+
if use_rest:
|
|
545
|
+
logger.info(
|
|
546
|
+
"Publishing promotion tip on %s via verified REST "
|
|
547
|
+
"(GraphQL cannot represent executable/symlink/mode changes)",
|
|
548
|
+
branch_name,
|
|
549
|
+
)
|
|
550
|
+
return self._rest_create_commit_on_branch(
|
|
551
|
+
branch_name=branch_name,
|
|
552
|
+
message=message,
|
|
553
|
+
expected_head_oid=expected,
|
|
554
|
+
file_paths=additions,
|
|
555
|
+
deletions=deletions,
|
|
556
|
+
)
|
|
557
|
+
return self._graphql_create_commit_on_branch(
|
|
558
|
+
branch_name=branch_name,
|
|
559
|
+
message=message,
|
|
560
|
+
expected_head_oid=expected,
|
|
561
|
+
additions=self._build_file_additions(
|
|
562
|
+
file_paths=additions,
|
|
563
|
+
repo_root=repo_root,
|
|
564
|
+
),
|
|
565
|
+
deletions=[{"path": self._normalize_repo_rel_path(path)} for path in deletions],
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
commit_sha = _publish_tip(base_sha)
|
|
570
|
+
except GithubException as err:
|
|
571
|
+
if not self._is_expected_head_mismatch(err):
|
|
572
|
+
raise
|
|
573
|
+
logger.warning(
|
|
574
|
+
"expectedHeadOid mismatch publishing tip on %s; retrying once: %s",
|
|
575
|
+
branch_name,
|
|
576
|
+
err,
|
|
577
|
+
)
|
|
578
|
+
self.fetch()
|
|
579
|
+
expected_head = self._ensure_remote_branch(branch_name=branch_name)
|
|
580
|
+
commit_sha = _publish_tip(expected_head)
|
|
581
|
+
self.fetch()
|
|
582
|
+
logger.info(
|
|
583
|
+
"Published verified promotion tip %s on %s (single ref update)",
|
|
584
|
+
commit_sha,
|
|
585
|
+
branch_name,
|
|
586
|
+
)
|
|
587
|
+
return commit_sha
|
|
588
|
+
|
|
589
|
+
def _auto_promote_api(
|
|
590
|
+
self,
|
|
591
|
+
*,
|
|
592
|
+
source_branch: str,
|
|
593
|
+
target_branch: str,
|
|
594
|
+
version: str,
|
|
595
|
+
source_version: str | None,
|
|
596
|
+
merge_message: str,
|
|
597
|
+
is_source_tag: bool,
|
|
598
|
+
post_merge_hook: Callable[[str, str], None] | None,
|
|
599
|
+
prefer_source_paths: Collection[str] | None = None,
|
|
600
|
+
remote_name: str = "origin",
|
|
601
|
+
) -> str:
|
|
602
|
+
"""
|
|
603
|
+
Promote via local integrate + one verified remote tip update.
|
|
604
|
+
|
|
605
|
+
Builds promote (and optional metadata) commits locally, then moves the
|
|
606
|
+
target branch once so concurrent workflows (e.g. Secret Scan) are not
|
|
607
|
+
canceled by a second push from the same job.
|
|
608
|
+
"""
|
|
609
|
+
self.fetch(remote_name=remote_name)
|
|
610
|
+
# Signed finalize may leave the worktree dirty if a prior sync was
|
|
611
|
+
# skipped; discard local dirt so checkout of the promote target works.
|
|
612
|
+
if self.repo.is_dirty(index=True, working_tree=True, untracked_files=False):
|
|
613
|
+
logger.warning(
|
|
614
|
+
"Dirty worktree before promote checkout; resetting to HEAD "
|
|
615
|
+
"(remote already has signed changes)"
|
|
616
|
+
)
|
|
617
|
+
self.repo.git.reset("--hard", "HEAD")
|
|
618
|
+
if target_branch in self.repo.heads:
|
|
619
|
+
self.checkout(branch_name=target_branch)
|
|
620
|
+
else:
|
|
621
|
+
self.checkout(
|
|
622
|
+
branch_name=target_branch,
|
|
623
|
+
create_from=f"{remote_name}/{target_branch}",
|
|
624
|
+
)
|
|
625
|
+
self.pull(branch_name=target_branch, remote_name=remote_name)
|
|
626
|
+
base_sha = self.repo.head.commit.hexsha
|
|
627
|
+
|
|
628
|
+
self._integrate_source_for_promotion(
|
|
629
|
+
source_ref=source_branch,
|
|
630
|
+
message=merge_message,
|
|
631
|
+
remote_name=remote_name,
|
|
632
|
+
is_tag=is_source_tag,
|
|
633
|
+
prefer_source_paths=prefer_source_paths,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
if post_merge_hook:
|
|
637
|
+
logger.info("Executing post-merge hook")
|
|
638
|
+
src_v = source_version if source_version else source_branch
|
|
639
|
+
try:
|
|
640
|
+
post_merge_hook(src_v, version)
|
|
641
|
+
dirty_paths = self._collect_dirty_tracked_paths()
|
|
642
|
+
if dirty_paths:
|
|
643
|
+
logger.info(
|
|
644
|
+
"Changes detected after post-merge hook; committing metadata locally: %s",
|
|
645
|
+
", ".join(dirty_paths),
|
|
646
|
+
)
|
|
647
|
+
for path in dirty_paths:
|
|
648
|
+
self.repo.git.add("--", path)
|
|
649
|
+
# Local-only; folded into the single verified tip published below.
|
|
650
|
+
self._local_commit(VERSION_METADATA_COMMIT.format(version=version))
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
logger.error(f"Post-merge hook failed: {exc}")
|
|
653
|
+
raise RuntimeError(f"Post-merge hook failed: {exc}") from exc
|
|
654
|
+
|
|
655
|
+
tip_sha = self._publish_local_tip_once(
|
|
656
|
+
branch_name=target_branch,
|
|
657
|
+
base_sha=base_sha,
|
|
658
|
+
message=merge_message,
|
|
659
|
+
)
|
|
660
|
+
self._api_create_lightweight_tag(tag=version, sha=tip_sha)
|
|
661
|
+
self.fetch(remote_name=remote_name)
|
|
662
|
+
logger.info(
|
|
663
|
+
"✅ Verified auto-promotion complete: %s → %s (tagged: %s)",
|
|
664
|
+
source_branch,
|
|
665
|
+
target_branch,
|
|
666
|
+
version,
|
|
667
|
+
)
|
|
668
|
+
return version
|