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,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