half-orm-dev 0.16.0a9__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.
- half_orm_dev/__init__.py +1 -0
- half_orm_dev/cli/__init__.py +9 -0
- half_orm_dev/cli/commands/__init__.py +56 -0
- half_orm_dev/cli/commands/apply.py +13 -0
- half_orm_dev/cli/commands/clone.py +102 -0
- half_orm_dev/cli/commands/init.py +331 -0
- half_orm_dev/cli/commands/new.py +15 -0
- half_orm_dev/cli/commands/patch.py +317 -0
- half_orm_dev/cli/commands/prepare.py +21 -0
- half_orm_dev/cli/commands/prepare_release.py +119 -0
- half_orm_dev/cli/commands/promote_to.py +127 -0
- half_orm_dev/cli/commands/release.py +344 -0
- half_orm_dev/cli/commands/restore.py +14 -0
- half_orm_dev/cli/commands/sync.py +13 -0
- half_orm_dev/cli/commands/todo.py +73 -0
- half_orm_dev/cli/commands/undo.py +17 -0
- half_orm_dev/cli/commands/update.py +73 -0
- half_orm_dev/cli/commands/upgrade.py +191 -0
- half_orm_dev/cli/main.py +103 -0
- half_orm_dev/cli_extension.py +38 -0
- half_orm_dev/database.py +1389 -0
- half_orm_dev/hgit.py +1025 -0
- half_orm_dev/hop.py +167 -0
- half_orm_dev/manifest.py +43 -0
- half_orm_dev/modules.py +456 -0
- half_orm_dev/patch.py +281 -0
- half_orm_dev/patch_manager.py +1694 -0
- half_orm_dev/patch_validator.py +335 -0
- half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
- half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
- half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
- half_orm_dev/patches/log +2 -0
- half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
- half_orm_dev/release_manager.py +2841 -0
- half_orm_dev/repo.py +1562 -0
- half_orm_dev/templates/.gitignore +15 -0
- half_orm_dev/templates/MANIFEST.in +1 -0
- half_orm_dev/templates/Pipfile +13 -0
- half_orm_dev/templates/README +25 -0
- half_orm_dev/templates/conftest_template +42 -0
- half_orm_dev/templates/init_module_template +10 -0
- half_orm_dev/templates/module_template_1 +12 -0
- half_orm_dev/templates/module_template_2 +6 -0
- half_orm_dev/templates/module_template_3 +3 -0
- half_orm_dev/templates/relation_test +23 -0
- half_orm_dev/templates/setup.py +81 -0
- half_orm_dev/templates/sql_adapter +9 -0
- half_orm_dev/templates/warning +12 -0
- half_orm_dev/utils.py +49 -0
- half_orm_dev/version.txt +1 -0
- half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
- half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
- half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
- half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +329 -0
|
@@ -0,0 +1,2841 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ReleaseManager module for half-orm-dev
|
|
3
|
+
|
|
4
|
+
Manages release files (releases/*.txt), version calculation, and release
|
|
5
|
+
lifecycle (stage → rc → production) for the Git-centric workflow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Tuple, List, Dict
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from git.exc import GitCommandError
|
|
19
|
+
|
|
20
|
+
class ReleaseManagerError(Exception):
|
|
21
|
+
"""Base exception for ReleaseManager operations."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReleaseVersionError(ReleaseManagerError):
|
|
26
|
+
"""Raised when version calculation or parsing fails."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReleaseFileError(ReleaseManagerError):
|
|
31
|
+
"""Raised when release file operations fail."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Version:
|
|
37
|
+
"""Semantic version with stage information."""
|
|
38
|
+
major: int
|
|
39
|
+
minor: int
|
|
40
|
+
patch: int
|
|
41
|
+
stage: Optional[str] = None # None, "stage", "rc1", "rc2", "hotfix1", etc.
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
"""String representation of version."""
|
|
45
|
+
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
46
|
+
if self.stage:
|
|
47
|
+
return f"{base}-{self.stage}"
|
|
48
|
+
return base
|
|
49
|
+
|
|
50
|
+
def __lt__(self, other: 'Version') -> bool:
|
|
51
|
+
"""Compare versions for sorting."""
|
|
52
|
+
# Compare base version first
|
|
53
|
+
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
|
54
|
+
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
|
55
|
+
|
|
56
|
+
# If base versions equal, compare stages
|
|
57
|
+
# Priority: production (None) > rc > stage > hotfix
|
|
58
|
+
stage_priority = {
|
|
59
|
+
None: 4, # Production (highest)
|
|
60
|
+
'rc': 3, # Release candidate
|
|
61
|
+
'stage': 2, # Development stage
|
|
62
|
+
'hotfix': 1 # Hotfix (lowest)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Extract stage type (rc1 → rc, hotfix2 → hotfix)
|
|
66
|
+
self_stage_type = self._get_stage_type()
|
|
67
|
+
other_stage_type = other._get_stage_type()
|
|
68
|
+
|
|
69
|
+
self_priority = stage_priority.get(self_stage_type, 0)
|
|
70
|
+
other_priority = stage_priority.get(other_stage_type, 0)
|
|
71
|
+
|
|
72
|
+
# If different stage types, compare by priority
|
|
73
|
+
if self_priority != other_priority:
|
|
74
|
+
return self_priority < other_priority
|
|
75
|
+
|
|
76
|
+
# Same stage type - compare stage strings for RC/hotfix numbers
|
|
77
|
+
# rc2 > rc1, hotfix2 > hotfix1
|
|
78
|
+
if self.stage and other.stage:
|
|
79
|
+
return self.stage < other.stage
|
|
80
|
+
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def _get_stage_type(self) -> Optional[str]:
|
|
84
|
+
"""Extract stage type from stage string."""
|
|
85
|
+
if not self.stage:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if self.stage == 'stage':
|
|
89
|
+
return 'stage'
|
|
90
|
+
elif self.stage.startswith('rc'):
|
|
91
|
+
return 'rc'
|
|
92
|
+
elif self.stage.startswith('hotfix'):
|
|
93
|
+
return 'hotfix'
|
|
94
|
+
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ReleaseManager:
|
|
99
|
+
"""
|
|
100
|
+
Manages release files and version lifecycle.
|
|
101
|
+
|
|
102
|
+
Handles creation, validation, and management of releases/*.txt files
|
|
103
|
+
following the Git-centric workflow specifications.
|
|
104
|
+
|
|
105
|
+
Release stages:
|
|
106
|
+
- X.Y.Z-stage.txt: Development stage (mutable)
|
|
107
|
+
- X.Y.Z-rc[N].txt: Release candidate (immutable)
|
|
108
|
+
- X.Y.Z.txt: Production release (immutable)
|
|
109
|
+
- X.Y.Z-hotfix[N].txt: Emergency hotfix (immutable)
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
# Prepare new release
|
|
113
|
+
release_mgr = ReleaseManager(repo)
|
|
114
|
+
result = release_mgr.prepare_release('minor')
|
|
115
|
+
# Creates releases/1.4.0-stage.txt
|
|
116
|
+
|
|
117
|
+
# Find latest version
|
|
118
|
+
version = release_mgr.find_latest_version()
|
|
119
|
+
print(f"Latest: {version}") # "1.3.5-rc2"
|
|
120
|
+
|
|
121
|
+
# Calculate next version
|
|
122
|
+
next_ver = release_mgr.calculate_next_version(version, 'patch')
|
|
123
|
+
print(f"Next: {next_ver}") # "1.3.6"
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, repo):
|
|
127
|
+
"""
|
|
128
|
+
Initialize ReleaseManager.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
repo: Repo instance providing access to repository state
|
|
132
|
+
"""
|
|
133
|
+
self._repo = repo
|
|
134
|
+
self._base_dir = str(repo.base_dir)
|
|
135
|
+
self._releases_dir = Path(repo.base_dir) / "releases"
|
|
136
|
+
|
|
137
|
+
def prepare_release(self, increment_type: str) -> dict:
|
|
138
|
+
"""
|
|
139
|
+
Prepare next release stage file.
|
|
140
|
+
|
|
141
|
+
Creates new releases/X.Y.Z-stage.txt file based on latest version
|
|
142
|
+
and increment type. Validates repository state, synchronizes with
|
|
143
|
+
origin, and pushes to reserve version globally.
|
|
144
|
+
|
|
145
|
+
Workflow:
|
|
146
|
+
1. Validate on ho-prod branch
|
|
147
|
+
2. Validate repository is clean
|
|
148
|
+
3. Fetch from origin
|
|
149
|
+
4. Synchronize with origin/ho-prod (pull if behind)
|
|
150
|
+
5. Read production version from model/schema.sql
|
|
151
|
+
6. Calculate next version based on increment type
|
|
152
|
+
7. Verify stage file doesn't already exist
|
|
153
|
+
8. Create empty stage file
|
|
154
|
+
9. Commit with message "Prepare release X.Y.Z-stage"
|
|
155
|
+
10. Push to origin (global reservation)
|
|
156
|
+
|
|
157
|
+
Branch requirements:
|
|
158
|
+
- Must be on ho-prod branch
|
|
159
|
+
- Repository must be clean (no uncommitted changes)
|
|
160
|
+
- Must be synced with origin/ho-prod (auto-pull if behind)
|
|
161
|
+
|
|
162
|
+
Synchronization behavior:
|
|
163
|
+
- "synced": Continue
|
|
164
|
+
- "behind": Auto-pull with message
|
|
165
|
+
- "ahead": Continue (will push at end)
|
|
166
|
+
- "diverged": Error - manual merge required
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
increment_type: Version increment ("major", "minor", or "patch")
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
dict: Preparation result with keys:
|
|
173
|
+
- version: New version string (e.g., "1.4.0")
|
|
174
|
+
- file: Path to created stage file
|
|
175
|
+
- previous_version: Previous production version
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ReleaseManagerError: If validation fails
|
|
179
|
+
ReleaseManagerError: If not on ho-prod branch
|
|
180
|
+
ReleaseManagerError: If repository not clean
|
|
181
|
+
ReleaseManagerError: If ho-prod diverged from origin
|
|
182
|
+
ReleaseFileError: If stage file already exists
|
|
183
|
+
ReleaseVersionError: If version calculation fails
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
# Prepare minor release
|
|
187
|
+
result = release_mgr.prepare_release('minor')
|
|
188
|
+
# Production was 1.3.5 → creates releases/1.4.0-stage.txt
|
|
189
|
+
|
|
190
|
+
# Prepare patch release
|
|
191
|
+
result = release_mgr.prepare_release('patch')
|
|
192
|
+
# Production was 1.3.5 → creates releases/1.3.6-stage.txt
|
|
193
|
+
|
|
194
|
+
# Error handling
|
|
195
|
+
try:
|
|
196
|
+
result = release_mgr.prepare_release('major')
|
|
197
|
+
except ReleaseManagerError as e:
|
|
198
|
+
print(f"Failed: {e}")
|
|
199
|
+
"""
|
|
200
|
+
# 1. Validate on ho-prod branch
|
|
201
|
+
if self._repo.hgit.branch != 'ho-prod':
|
|
202
|
+
raise ReleaseManagerError(
|
|
203
|
+
f"Must be on ho-prod branch to prepare release.\n"
|
|
204
|
+
f"Current branch: {self._repo.hgit.branch}\n"
|
|
205
|
+
f"Switch to ho-prod: git checkout ho-prod"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# 2. Validate repository is clean
|
|
209
|
+
if not self._repo.hgit.repos_is_clean():
|
|
210
|
+
raise ReleaseManagerError(
|
|
211
|
+
"Repository has uncommitted changes.\n"
|
|
212
|
+
"Commit or stash changes before preparing release:\n"
|
|
213
|
+
" git status\n"
|
|
214
|
+
" git add . && git commit"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# 3. Fetch from origin
|
|
218
|
+
self._repo.hgit.fetch_from_origin()
|
|
219
|
+
|
|
220
|
+
# 4. Synchronize with origin
|
|
221
|
+
is_synced, status = self._repo.hgit.is_branch_synced("ho-prod")
|
|
222
|
+
|
|
223
|
+
if status == "behind":
|
|
224
|
+
# Pull automatically
|
|
225
|
+
self._repo.hgit.pull()
|
|
226
|
+
elif status == "diverged":
|
|
227
|
+
raise ReleaseManagerError(
|
|
228
|
+
"ho-prod has diverged from origin/ho-prod.\n"
|
|
229
|
+
"Manual resolution required:\n"
|
|
230
|
+
" git pull --rebase origin ho-prod\n"
|
|
231
|
+
" or\n"
|
|
232
|
+
" git merge origin/ho-prod"
|
|
233
|
+
)
|
|
234
|
+
# If "synced" or "ahead", continue
|
|
235
|
+
|
|
236
|
+
# 5. Read production version from model/schema.sql
|
|
237
|
+
prod_version_str = self._get_production_version()
|
|
238
|
+
|
|
239
|
+
# Parse into Version object for calculation
|
|
240
|
+
prod_version = self.parse_version_from_filename(f"{prod_version_str}.txt")
|
|
241
|
+
|
|
242
|
+
# 6. Calculate next version
|
|
243
|
+
next_version = self.calculate_next_version(prod_version, increment_type)
|
|
244
|
+
|
|
245
|
+
# 7. Verify stage file doesn't exist
|
|
246
|
+
stage_file = self._releases_dir / f"{next_version}-stage.txt"
|
|
247
|
+
if stage_file.exists():
|
|
248
|
+
raise ReleaseFileError(
|
|
249
|
+
f"Stage file already exists: {stage_file}\n"
|
|
250
|
+
f"Version {next_version} is already in development.\n"
|
|
251
|
+
f"To continue with this version, use existing stage file."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# 8. Create empty stage file
|
|
255
|
+
stage_file.touch()
|
|
256
|
+
|
|
257
|
+
# 9. Commit
|
|
258
|
+
self._repo.hgit.add(str(stage_file))
|
|
259
|
+
self._repo.hgit.commit("-m", f"Prepare release {next_version}-stage")
|
|
260
|
+
|
|
261
|
+
# 10. Push to origin (global reservation)
|
|
262
|
+
self._repo.hgit.push()
|
|
263
|
+
|
|
264
|
+
# Return result
|
|
265
|
+
return {
|
|
266
|
+
'version': next_version,
|
|
267
|
+
'file': str(stage_file),
|
|
268
|
+
'previous_version': prod_version_str
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
def _get_production_version(self) -> str:
|
|
272
|
+
"""
|
|
273
|
+
Get production version from model/schema.sql symlink.
|
|
274
|
+
|
|
275
|
+
Reads the version from model/schema.sql symlink target filename.
|
|
276
|
+
Validates consistency with database metadata if accessible.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
str: Production version (e.g., "1.3.5")
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ReleaseFileError: If model/ directory or schema.sql missing
|
|
283
|
+
ReleaseFileError: If symlink target has invalid format
|
|
284
|
+
|
|
285
|
+
Examples:
|
|
286
|
+
# schema.sql -> schema-1.3.5.sql
|
|
287
|
+
version = mgr._get_production_version()
|
|
288
|
+
# Returns: "1.3.5"
|
|
289
|
+
"""
|
|
290
|
+
schema_path = Path(self._base_dir) / "model" / "schema.sql"
|
|
291
|
+
|
|
292
|
+
# Parse version from symlink
|
|
293
|
+
version_from_file = self._parse_version_from_symlink(schema_path)
|
|
294
|
+
|
|
295
|
+
# Optional validation against database
|
|
296
|
+
try:
|
|
297
|
+
version_from_db = self._repo.database.last_release_s
|
|
298
|
+
if version_from_file != version_from_db:
|
|
299
|
+
self._repo.restore_database_from_schema()
|
|
300
|
+
except Exception:
|
|
301
|
+
# Database not accessible or no metadata: OK, continue
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
return version_from_file
|
|
305
|
+
|
|
306
|
+
def _parse_version_from_symlink(self, schema_path: Path) -> str:
|
|
307
|
+
"""
|
|
308
|
+
Parse version from model/schema.sql symlink target.
|
|
309
|
+
|
|
310
|
+
Extracts version number from symlink target filename following
|
|
311
|
+
the pattern schema-X.Y.Z.sql.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
schema_path: Path to model/schema.sql symlink
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
str: Version string (e.g., "1.3.5")
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ReleaseFileError: If symlink missing, broken, or invalid format
|
|
321
|
+
|
|
322
|
+
Examples:
|
|
323
|
+
# schema.sql -> schema-1.3.5.sql
|
|
324
|
+
version = mgr._parse_version_from_symlink(Path("model/schema.sql"))
|
|
325
|
+
# Returns: "1.3.5"
|
|
326
|
+
"""
|
|
327
|
+
import re
|
|
328
|
+
|
|
329
|
+
# Check model/ directory exists
|
|
330
|
+
model_dir = schema_path.parent
|
|
331
|
+
if not model_dir.exists():
|
|
332
|
+
raise ReleaseFileError(
|
|
333
|
+
f"Model directory not found: {model_dir}\n"
|
|
334
|
+
"Run 'half_orm dev init-project' first."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Check schema.sql exists
|
|
338
|
+
if not schema_path.exists():
|
|
339
|
+
raise ReleaseFileError(
|
|
340
|
+
f"Production schema file not found: {schema_path}\n"
|
|
341
|
+
"Run 'half_orm dev init-project' to generate initial schema."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Check it's a symlink
|
|
345
|
+
if not schema_path.is_symlink():
|
|
346
|
+
raise ReleaseFileError(
|
|
347
|
+
f"Expected symlink but found regular file: {schema_path}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Get symlink target
|
|
351
|
+
target = Path(os.readlink(schema_path))
|
|
352
|
+
target_name = target.name if hasattr(target, 'name') else str(target)
|
|
353
|
+
|
|
354
|
+
# Parse version from target filename: schema-X.Y.Z.sql
|
|
355
|
+
pattern = r'^schema-(\d+\.\d+\.\d+)\.sql$'
|
|
356
|
+
match = re.match(pattern, target_name)
|
|
357
|
+
|
|
358
|
+
if not match:
|
|
359
|
+
raise ReleaseFileError(
|
|
360
|
+
f"Invalid schema symlink target format: {target_name}\n"
|
|
361
|
+
f"Expected: schema-X.Y.Z.sql (e.g., schema-1.3.5.sql)"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Extract version from capture group
|
|
365
|
+
version = match.group(1)
|
|
366
|
+
|
|
367
|
+
return version
|
|
368
|
+
|
|
369
|
+
def find_latest_version(self) -> Optional[Version]:
|
|
370
|
+
"""
|
|
371
|
+
Find latest version across all release stages.
|
|
372
|
+
|
|
373
|
+
Scans releases/ directory for all .txt files and identifies the
|
|
374
|
+
highest version considering stage priority:
|
|
375
|
+
- Production releases (X.Y.Z.txt) have highest priority
|
|
376
|
+
- RC releases (X.Y.Z-rc[N].txt) have second priority
|
|
377
|
+
- Stage releases (X.Y.Z-stage.txt) have third priority
|
|
378
|
+
- Hotfix releases (X.Y.Z-hotfix[N].txt) have fourth priority
|
|
379
|
+
|
|
380
|
+
Returns None if no release files exist (first release).
|
|
381
|
+
|
|
382
|
+
Version comparison:
|
|
383
|
+
- Base version compared first (1.4.0 > 1.3.9)
|
|
384
|
+
- Stage priority used for same base (1.3.5.txt > 1.3.5-rc2.txt)
|
|
385
|
+
- RC number compared within RC stage (1.3.5-rc2 > 1.3.5-rc1)
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Optional[Version]: Latest version or None if no releases exist
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
ReleaseVersionError: If version parsing fails
|
|
392
|
+
ReleaseFileError: If releases/ directory not found
|
|
393
|
+
|
|
394
|
+
Examples:
|
|
395
|
+
# With releases/1.3.4.txt, releases/1.3.5-stage.txt
|
|
396
|
+
version = release_mgr.find_latest_version()
|
|
397
|
+
print(version) # "1.3.5-stage"
|
|
398
|
+
|
|
399
|
+
# With releases/1.3.4.txt, releases/1.3.5-rc2.txt
|
|
400
|
+
version = release_mgr.find_latest_version()
|
|
401
|
+
print(version) # "1.3.5-rc2"
|
|
402
|
+
|
|
403
|
+
# No release files
|
|
404
|
+
version = release_mgr.find_latest_version()
|
|
405
|
+
print(version) # None
|
|
406
|
+
"""
|
|
407
|
+
# Check releases/ directory exists
|
|
408
|
+
if not self._releases_dir.exists():
|
|
409
|
+
raise ReleaseFileError(
|
|
410
|
+
f"Releases directory not found: {self._releases_dir}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Get all .txt files in releases/
|
|
414
|
+
release_files = list(self._releases_dir.glob("*.txt"))
|
|
415
|
+
|
|
416
|
+
if not release_files:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
# Parse all valid versions
|
|
420
|
+
versions = []
|
|
421
|
+
for release_file in release_files:
|
|
422
|
+
try:
|
|
423
|
+
version = self.parse_version_from_filename(release_file.name)
|
|
424
|
+
versions.append(version)
|
|
425
|
+
except ReleaseVersionError:
|
|
426
|
+
# Ignore files with invalid format
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
if not versions:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
# Sort versions and return latest
|
|
433
|
+
# Version.__lt__ handles sorting with stage priority
|
|
434
|
+
return max(versions)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def calculate_next_version(
|
|
438
|
+
self,
|
|
439
|
+
current_version: Optional[Version],
|
|
440
|
+
increment_type: str
|
|
441
|
+
) -> str:
|
|
442
|
+
"""
|
|
443
|
+
Calculate next version based on increment type.
|
|
444
|
+
|
|
445
|
+
Computes the next semantic version from current version and
|
|
446
|
+
increment type. Handles first release (0.0.1) when no current
|
|
447
|
+
version exists.
|
|
448
|
+
|
|
449
|
+
Increment rules:
|
|
450
|
+
- "major": Increment major, reset minor and patch to 0
|
|
451
|
+
- "minor": Keep major, increment minor, reset patch to 0
|
|
452
|
+
- "patch": Keep major and minor, increment patch
|
|
453
|
+
|
|
454
|
+
Examples with current version 1.3.5:
|
|
455
|
+
- major → 2.0.0
|
|
456
|
+
- minor → 1.4.0
|
|
457
|
+
- patch → 1.3.6
|
|
458
|
+
|
|
459
|
+
First release (current_version is None):
|
|
460
|
+
- Any increment type → 0.0.1
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
current_version: Current version or None for first release
|
|
464
|
+
increment_type: "major", "minor", or "patch"
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
str: Next version string (e.g., "1.4.0", "2.0.0")
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
ReleaseVersionError: If increment_type invalid
|
|
471
|
+
|
|
472
|
+
Examples:
|
|
473
|
+
# From 1.3.5 to major
|
|
474
|
+
version = Version(1, 3, 5)
|
|
475
|
+
next_ver = release_mgr.calculate_next_version(version, 'major')
|
|
476
|
+
print(next_ver) # "2.0.0"
|
|
477
|
+
|
|
478
|
+
# From 1.3.5 to minor
|
|
479
|
+
next_ver = release_mgr.calculate_next_version(version, 'minor')
|
|
480
|
+
print(next_ver) # "1.4.0"
|
|
481
|
+
|
|
482
|
+
# From 1.3.5 to patch
|
|
483
|
+
next_ver = release_mgr.calculate_next_version(version, 'patch')
|
|
484
|
+
print(next_ver) # "1.3.6"
|
|
485
|
+
|
|
486
|
+
# First release
|
|
487
|
+
next_ver = release_mgr.calculate_next_version(None, 'minor')
|
|
488
|
+
print(next_ver) # "0.0.1"
|
|
489
|
+
"""
|
|
490
|
+
# Validate increment type
|
|
491
|
+
valid_types = ['major', 'minor', 'patch']
|
|
492
|
+
if not increment_type or increment_type not in valid_types:
|
|
493
|
+
raise ReleaseVersionError(
|
|
494
|
+
f"Invalid increment type: '{increment_type}'. "
|
|
495
|
+
f"Must be one of: {', '.join(valid_types)}"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Calculate next version based on increment type
|
|
499
|
+
if increment_type == 'major':
|
|
500
|
+
return f"{current_version.major + 1}.0.0"
|
|
501
|
+
elif increment_type == 'minor':
|
|
502
|
+
return f"{current_version.major}.{current_version.minor + 1}.0"
|
|
503
|
+
elif increment_type == 'patch':
|
|
504
|
+
return f"{current_version.major}.{current_version.minor}.{current_version.patch + 1}"
|
|
505
|
+
|
|
506
|
+
# Should never reach here due to validation above
|
|
507
|
+
raise ReleaseVersionError(f"Unexpected increment type: {increment_type}")
|
|
508
|
+
|
|
509
|
+
@classmethod
|
|
510
|
+
def parse_version_from_filename(cls, filename: str) -> Version:
|
|
511
|
+
"""
|
|
512
|
+
Parse version from release filename.
|
|
513
|
+
|
|
514
|
+
Extracts semantic version and stage from release filename.
|
|
515
|
+
|
|
516
|
+
Supported formats:
|
|
517
|
+
- X.Y.Z.txt → Version(X, Y, Z, stage=None)
|
|
518
|
+
- X.Y.Z-stage.txt → Version(X, Y, Z, stage="stage")
|
|
519
|
+
- X.Y.Z-rc1.txt → Version(X, Y, Z, stage="rc1")
|
|
520
|
+
- X.Y.Z-hotfix1.txt → Version(X, Y, Z, stage="hotfix1")
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
filename: Release filename (e.g., "1.3.5-rc2.txt")
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Version: Parsed version object
|
|
527
|
+
|
|
528
|
+
Raises:
|
|
529
|
+
ReleaseVersionError: If filename format invalid
|
|
530
|
+
|
|
531
|
+
Examples:
|
|
532
|
+
ver = release_mgr.parse_version_from_filename("1.3.5.txt")
|
|
533
|
+
# Version(1, 3, 5, stage=None)
|
|
534
|
+
|
|
535
|
+
ver = release_mgr.parse_version_from_filename("1.4.0-stage.txt")
|
|
536
|
+
# Version(1, 4, 0, stage="stage")
|
|
537
|
+
|
|
538
|
+
ver = release_mgr.parse_version_from_filename("1.3.5-rc2.txt")
|
|
539
|
+
# Version(1, 3, 5, stage="rc2")
|
|
540
|
+
"""
|
|
541
|
+
import re
|
|
542
|
+
from pathlib import Path
|
|
543
|
+
|
|
544
|
+
# Extract just filename if path provided
|
|
545
|
+
filename = Path(filename).name
|
|
546
|
+
|
|
547
|
+
# Validate not empty
|
|
548
|
+
if not filename:
|
|
549
|
+
raise ReleaseVersionError("Invalid format: empty filename")
|
|
550
|
+
|
|
551
|
+
# Must end with .txt
|
|
552
|
+
if not filename.endswith('.txt'):
|
|
553
|
+
raise ReleaseVersionError(f"Invalid format: missing .txt extension in '{filename}'")
|
|
554
|
+
|
|
555
|
+
# Remove .txt extension
|
|
556
|
+
version_str = filename[:-4]
|
|
557
|
+
|
|
558
|
+
# Pattern: X.Y.Z or X.Y.Z-stage or X.Y.Z-rc1 or X.Y.Z-hotfix1
|
|
559
|
+
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(stage|rc\d+|hotfix\d+))?$'
|
|
560
|
+
|
|
561
|
+
match = re.match(pattern, version_str)
|
|
562
|
+
|
|
563
|
+
if not match:
|
|
564
|
+
raise ReleaseVersionError(
|
|
565
|
+
f"Invalid format: '{filename}' does not match X.Y.Z[-stage].txt pattern"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
major, minor, patch, stage = match.groups()
|
|
569
|
+
|
|
570
|
+
# Convert to integers
|
|
571
|
+
try:
|
|
572
|
+
major = int(major)
|
|
573
|
+
minor = int(minor)
|
|
574
|
+
patch = int(patch)
|
|
575
|
+
except ValueError:
|
|
576
|
+
raise ReleaseVersionError(f"Invalid format: non-numeric version components in '{filename}'")
|
|
577
|
+
|
|
578
|
+
# Validate non-negative
|
|
579
|
+
if major < 0 or minor < 0 or patch < 0:
|
|
580
|
+
raise ReleaseVersionError(f"Invalid format: negative version numbers in '{filename}'")
|
|
581
|
+
|
|
582
|
+
return Version(major, minor, patch, stage)
|
|
583
|
+
|
|
584
|
+
def get_next_release_version(self) -> Optional[str]:
|
|
585
|
+
"""
|
|
586
|
+
Détermine LA prochaine release à déployer.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
Version string ou None
|
|
590
|
+
"""
|
|
591
|
+
production_str = self._get_production_version()
|
|
592
|
+
|
|
593
|
+
for level in ['patch', 'minor', 'major']:
|
|
594
|
+
next_version = self.calculate_next_version(
|
|
595
|
+
self.parse_version_from_filename(f"{production_str}.txt"), level)
|
|
596
|
+
|
|
597
|
+
# Cherche RC ou stage pour cette version
|
|
598
|
+
rc_pattern = f"{next_version}-rc*.txt"
|
|
599
|
+
stage_file = self._releases_dir / f"{next_version}-stage.txt"
|
|
600
|
+
|
|
601
|
+
if list(self._releases_dir.glob(rc_pattern)) or stage_file.exists():
|
|
602
|
+
return next_version
|
|
603
|
+
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
def get_rc_files(self, version: str) -> List[str]:
|
|
607
|
+
"""
|
|
608
|
+
Liste tous les fichiers RC pour une version, triés par numéro.
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
Liste triée (ex: ["1.3.6-rc1.txt", "1.3.6-rc2.txt"])
|
|
612
|
+
"""
|
|
613
|
+
pattern = f"{version}-rc*.txt"
|
|
614
|
+
rc_pattern = re.compile(r'-rc(\d+)\.txt$')
|
|
615
|
+
rc_files = list(self._releases_dir.glob(pattern))
|
|
616
|
+
|
|
617
|
+
return sorted(rc_files, key=lambda f: int(re.search(rc_pattern, f.name).group(1)))
|
|
618
|
+
|
|
619
|
+
def read_release_patches(self, filename: str) -> List[str]:
|
|
620
|
+
"""
|
|
621
|
+
Lit les patch IDs d'un fichier de release.
|
|
622
|
+
|
|
623
|
+
Ignore:
|
|
624
|
+
- Lignes vides
|
|
625
|
+
- Commentaires (#)
|
|
626
|
+
- Whitespace
|
|
627
|
+
"""
|
|
628
|
+
file_path = self._releases_dir / filename
|
|
629
|
+
|
|
630
|
+
if not file_path.exists():
|
|
631
|
+
return []
|
|
632
|
+
|
|
633
|
+
patch_ids = []
|
|
634
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
635
|
+
for line in f:
|
|
636
|
+
line = line.strip()
|
|
637
|
+
if line and not line.startswith('#'):
|
|
638
|
+
patch_ids.append(line)
|
|
639
|
+
|
|
640
|
+
return patch_ids
|
|
641
|
+
|
|
642
|
+
def get_all_release_context_patches(self) -> List[str]:
|
|
643
|
+
"""
|
|
644
|
+
Récupère TOUS les patches du contexte de la prochaine release.
|
|
645
|
+
|
|
646
|
+
IMPORTANT: Application séquentielle des RC incrémentaux.
|
|
647
|
+
- rc1: patches initiaux (ex: 123, 456, 789)
|
|
648
|
+
- rc2: patches nouveaux (ex: 999)
|
|
649
|
+
- rc3: patches nouveaux (ex: 888, 777)
|
|
650
|
+
|
|
651
|
+
Résultat: [123, 456, 789, 999, 888, 777]
|
|
652
|
+
|
|
653
|
+
Pas de déduplication car chaque RC est incrémental.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Liste ordonnée des patch IDs (séquence complète)
|
|
657
|
+
|
|
658
|
+
Examples:
|
|
659
|
+
# Production: 1.3.5
|
|
660
|
+
# 1.3.6-rc1.txt: 123, 456, 789
|
|
661
|
+
# 1.3.6-rc2.txt: 999
|
|
662
|
+
# 1.3.6-stage.txt: 234, 567
|
|
663
|
+
|
|
664
|
+
patches = mgr.get_all_release_context_patches()
|
|
665
|
+
# → ["123", "456", "789", "999", "234", "567"]
|
|
666
|
+
|
|
667
|
+
# Pour apply-patch sur patch 888:
|
|
668
|
+
# 1. Restore DB (1.3.5)
|
|
669
|
+
# 2. Apply 123, 456, 789 (rc1)
|
|
670
|
+
# 3. Apply 999 (rc2)
|
|
671
|
+
# 4. Apply 234, 567 (stage)
|
|
672
|
+
# 5. Apply 888 (patch courant)
|
|
673
|
+
"""
|
|
674
|
+
next_version = self.get_next_release_version()
|
|
675
|
+
|
|
676
|
+
if not next_version:
|
|
677
|
+
return []
|
|
678
|
+
|
|
679
|
+
all_patches = []
|
|
680
|
+
|
|
681
|
+
# 1. Appliquer tous les RC dans l'ordre (incrémentaux)
|
|
682
|
+
rc_files = self.get_rc_files(next_version)
|
|
683
|
+
for rc_file in rc_files:
|
|
684
|
+
patches = self.read_release_patches(rc_file)
|
|
685
|
+
# Chaque RC est incrémental, pas besoin de déduplication
|
|
686
|
+
all_patches.extend(patches)
|
|
687
|
+
|
|
688
|
+
# 2. Appliquer stage (nouveaux patches en développement)
|
|
689
|
+
stage_file = f"{next_version}-stage.txt"
|
|
690
|
+
stage_patches = self.read_release_patches(stage_file)
|
|
691
|
+
all_patches.extend(stage_patches)
|
|
692
|
+
|
|
693
|
+
return all_patches
|
|
694
|
+
|
|
695
|
+
def add_patch_to_release(self, patch_id: str, to_version: Optional[str] = None) -> dict:
|
|
696
|
+
"""
|
|
697
|
+
Add patch to stage release file with validation and exclusive lock.
|
|
698
|
+
|
|
699
|
+
Complete workflow with distributed lock to prevent race conditions:
|
|
700
|
+
1. Pre-lock validations (branch, clean, patch exists)
|
|
701
|
+
2. Detect target stage file (auto or explicit)
|
|
702
|
+
3. Check patch not already in release
|
|
703
|
+
4. Acquire exclusive lock on ho-prod (atomic via Git tag)
|
|
704
|
+
5. Sync with origin (fetch + pull if needed)
|
|
705
|
+
6. Create temporary validation branch FROM ho-prod
|
|
706
|
+
7. Merge ALL patches already in release (from ho-release/X.Y.Z/* branches)
|
|
707
|
+
8. Merge new patch branch (from ho-patch/{patch_id})
|
|
708
|
+
9. Add patch to stage file on temp branch + commit
|
|
709
|
+
10. Run validation tests (with ALL patches integrated)
|
|
710
|
+
11. If tests fail: cleanup temp branch, release lock, exit with error
|
|
711
|
+
12. If tests pass: return to ho-prod, delete temp branch
|
|
712
|
+
13. Add patch to stage file on ho-prod + commit (file change only)
|
|
713
|
+
14. Push ho-prod to origin
|
|
714
|
+
16. Archive patch branch to ho-release/{version}/{patch_id}
|
|
715
|
+
17. Release lock (in finally block)
|
|
716
|
+
|
|
717
|
+
CRITICAL: ho-prod NEVER contains patch code directly. It only contains
|
|
718
|
+
the releases/*.txt files that list which patches are in each release.
|
|
719
|
+
The temp-valid branch is used to test the integration of ALL patches
|
|
720
|
+
together, but only the release file change is committed to ho-prod.
|
|
721
|
+
Actual patch code remains in archived branches (ho-release/X.Y.Z/*).
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
patch_id: Patch identifier (e.g., "456-user-auth")
|
|
725
|
+
to_version: Optional explicit version (e.g., "1.3.6")
|
|
726
|
+
Required if multiple stage releases exist
|
|
727
|
+
Auto-detected if only one stage exists
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
{
|
|
731
|
+
'status': 'success',
|
|
732
|
+
'patch_id': str, # "456-user-auth"
|
|
733
|
+
'target_version': str, # "1.3.6"
|
|
734
|
+
'stage_file': str, # "1.3.6-stage.txt"
|
|
735
|
+
'temp_branch': str, # "temp-valid-1.3.6"
|
|
736
|
+
'tests_passed': bool, # True
|
|
737
|
+
'archived_branch': str, # "ho-release/1.3.6/456-user-auth"
|
|
738
|
+
'commit_sha': str, # SHA of ho-prod commit
|
|
739
|
+
'patches_in_release': List[str], # All patches after add
|
|
740
|
+
'notifications_sent': List[str], # Branches notified
|
|
741
|
+
'lock_tag': str # "lock-ho-prod-1704123456789"
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
Raises:
|
|
745
|
+
ReleaseManagerError: If validations fail:
|
|
746
|
+
- Not on ho-prod branch
|
|
747
|
+
- Repository not clean
|
|
748
|
+
- Patch doesn't exist (Patches/{patch_id}/)
|
|
749
|
+
- Branch doesn't exist (ho-patch/{patch_id})
|
|
750
|
+
- No stage release found
|
|
751
|
+
- Multiple stages without --to-version
|
|
752
|
+
- Specified stage doesn't exist
|
|
753
|
+
- Patch already in release
|
|
754
|
+
- Lock acquisition failed (another process holds lock)
|
|
755
|
+
- ho-prod diverged from origin
|
|
756
|
+
- Merge conflicts during integration
|
|
757
|
+
- Tests failed on temp branch
|
|
758
|
+
- Push failed
|
|
759
|
+
|
|
760
|
+
Examples:
|
|
761
|
+
# Add patch to auto-detected stage (one stage exists)
|
|
762
|
+
result = release_mgr.add_patch_to_release("456-user-auth")
|
|
763
|
+
# → Creates temp-valid-1.3.6
|
|
764
|
+
# → Merges all patches from releases/1.3.6-stage.txt
|
|
765
|
+
# → Merges ho-patch/456-user-auth
|
|
766
|
+
# → Tests complete integration
|
|
767
|
+
# → Updates releases/1.3.6-stage.txt on ho-prod
|
|
768
|
+
# → Archives to ho-release/1.3.6/456-user-auth
|
|
769
|
+
|
|
770
|
+
# Add patch to explicit version (multiple stages)
|
|
771
|
+
result = release_mgr.add_patch_to_release(
|
|
772
|
+
"456-user-auth",
|
|
773
|
+
to_version="1.3.6"
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Error handling
|
|
777
|
+
try:
|
|
778
|
+
result = release_mgr.add_patch_to_release("456-user-auth")
|
|
779
|
+
except ReleaseManagerError as e:
|
|
780
|
+
if "locked" in str(e):
|
|
781
|
+
print("Another add-to-release in progress, retry later")
|
|
782
|
+
elif "Tests failed" in str(e):
|
|
783
|
+
print("Patch breaks integration, fix and retry")
|
|
784
|
+
elif "Merge conflict" in str(e):
|
|
785
|
+
print("Patch conflicts with existing patches")
|
|
786
|
+
"""
|
|
787
|
+
# 1. Pre-lock validations
|
|
788
|
+
if self._repo.hgit.branch != "ho-prod":
|
|
789
|
+
raise ReleaseManagerError(
|
|
790
|
+
"Must be on ho-prod branch to add patch to release.\n"
|
|
791
|
+
f"Current branch: {self._repo.hgit.branch}"
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
if not self._repo.hgit.repos_is_clean():
|
|
795
|
+
raise ReleaseManagerError(
|
|
796
|
+
"Repository has uncommitted changes. Commit or stash first."
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
# Check patch directory exists
|
|
800
|
+
patch_dir = Path(self._repo.base_dir) / "Patches" / patch_id
|
|
801
|
+
if not patch_dir.exists():
|
|
802
|
+
raise ReleaseManagerError(
|
|
803
|
+
f"Patch directory not found: Patches/{patch_id}/\n"
|
|
804
|
+
f"Create patch first with: half_orm dev create-patch"
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
# Check patch branch exists
|
|
808
|
+
if not self._repo.hgit.branch_exists(f"ho-patch/{patch_id}"):
|
|
809
|
+
raise ReleaseManagerError(
|
|
810
|
+
f"Branch ho-patch/{patch_id} not found locally.\n"
|
|
811
|
+
f"Checkout branch first: git checkout ho-patch/{patch_id}"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
# 2. Detect target stage file
|
|
815
|
+
target_version, stage_file = self._detect_target_stage_file(to_version)
|
|
816
|
+
|
|
817
|
+
# 3. Check patch not already in release
|
|
818
|
+
existing_patches = self.read_release_patches(stage_file)
|
|
819
|
+
if patch_id in existing_patches:
|
|
820
|
+
raise ReleaseManagerError(
|
|
821
|
+
f"Patch {patch_id} already in release {target_version}-stage.\n"
|
|
822
|
+
f"Nothing to do."
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# 4. ACQUIRE LOCK on ho-prod (with 30 min timeout for stale locks)
|
|
826
|
+
lock_tag = self._repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
|
|
827
|
+
|
|
828
|
+
try:
|
|
829
|
+
sync_result = self._ensure_patch_branch_synced(patch_id)
|
|
830
|
+
|
|
831
|
+
if sync_result['strategy'] != 'already-synced':
|
|
832
|
+
# Log successful auto-sync
|
|
833
|
+
import sys
|
|
834
|
+
print(
|
|
835
|
+
f"✓ Auto-synced {sync_result['branch_name']} with ho-prod "
|
|
836
|
+
f"({sync_result['strategy']})",
|
|
837
|
+
file=sys.stderr
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
except ReleaseManagerError as e:
|
|
841
|
+
# Manual resolution required - release lock and exit
|
|
842
|
+
# Lock will be released in finally block
|
|
843
|
+
raise
|
|
844
|
+
|
|
845
|
+
temp_branch = f"temp-valid-{target_version}"
|
|
846
|
+
|
|
847
|
+
try:
|
|
848
|
+
# 5. Sync with origin (now that we have lock)
|
|
849
|
+
self._repo.hgit.fetch_from_origin()
|
|
850
|
+
is_synced, status = self._repo.hgit.is_branch_synced("ho-prod")
|
|
851
|
+
|
|
852
|
+
if status == "behind":
|
|
853
|
+
self._repo.hgit.pull()
|
|
854
|
+
elif status == "diverged":
|
|
855
|
+
raise ReleaseManagerError(
|
|
856
|
+
"Branch ho-prod has diverged from origin.\n"
|
|
857
|
+
"Manual merge or rebase required."
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# 6. Create temporary validation branch FROM ho-prod
|
|
861
|
+
self._repo.hgit.checkout("-b", temp_branch)
|
|
862
|
+
|
|
863
|
+
# 7. Merge ALL existing patches in the release (already validated)
|
|
864
|
+
for existing_patch_id in existing_patches:
|
|
865
|
+
archived_branch = f"ho-release/{target_version}/{existing_patch_id}"
|
|
866
|
+
if self._repo.hgit.branch_exists(archived_branch):
|
|
867
|
+
try:
|
|
868
|
+
self._repo.hgit.merge(
|
|
869
|
+
archived_branch,
|
|
870
|
+
no_ff=True,
|
|
871
|
+
m=f"Merge {existing_patch_id} (already in release)"
|
|
872
|
+
)
|
|
873
|
+
except Exception as e:
|
|
874
|
+
# Should not happen (already validated), but handle it
|
|
875
|
+
self._repo.hgit.checkout("ho-prod")
|
|
876
|
+
self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
|
|
877
|
+
raise ReleaseManagerError(
|
|
878
|
+
f"Failed to merge existing patch {existing_patch_id}.\n"
|
|
879
|
+
f"This should not happen (patch already validated).\n"
|
|
880
|
+
f"Manual intervention required.\n"
|
|
881
|
+
f"Error: {e}"
|
|
882
|
+
)
|
|
883
|
+
else:
|
|
884
|
+
# Branch not found - might be an old patch before archiving system
|
|
885
|
+
import sys
|
|
886
|
+
sys.stderr.write(
|
|
887
|
+
f"Warning: Branch {archived_branch} not found. "
|
|
888
|
+
f"Patch {existing_patch_id} might be from old workflow.\n"
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# 8. Merge new patch branch into temp-valid
|
|
892
|
+
try:
|
|
893
|
+
self._repo.hgit.merge(
|
|
894
|
+
f"ho-patch/{patch_id}",
|
|
895
|
+
no_ff=True,
|
|
896
|
+
m=f"Merge {patch_id} for validation in {target_version}-stage"
|
|
897
|
+
)
|
|
898
|
+
except Exception as e:
|
|
899
|
+
# Merge conflict - cleanup and exit
|
|
900
|
+
self._repo.hgit.checkout("ho-prod")
|
|
901
|
+
self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
|
|
902
|
+
raise ReleaseManagerError(
|
|
903
|
+
f"Merge conflict integrating {patch_id}.\n"
|
|
904
|
+
f"The patch conflicts with existing patches in the release.\n"
|
|
905
|
+
f"Resolve conflicts manually:\n"
|
|
906
|
+
f" 1. git checkout ho-patch/{patch_id}\n"
|
|
907
|
+
f" 2. git merge ho-prod\n"
|
|
908
|
+
f" 3. Resolve conflicts\n"
|
|
909
|
+
f" 4. half_orm dev apply-patch (re-test)\n"
|
|
910
|
+
f" 5. Retry add-to-release\n"
|
|
911
|
+
f"Error: {e}"
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# 9. Add patch to stage file on temp branch
|
|
915
|
+
self._apply_patch_change_to_stage_file(stage_file, patch_id)
|
|
916
|
+
|
|
917
|
+
# 10. Commit release file on temp branch
|
|
918
|
+
commit_msg = f"Add {patch_id} to release {target_version}-stage (validation)"
|
|
919
|
+
self._repo.hgit.add(str(self._releases_dir / stage_file))
|
|
920
|
+
self._repo.hgit.commit("-m", commit_msg)
|
|
921
|
+
|
|
922
|
+
# 11. Run validation tests (ALL patches integrated)
|
|
923
|
+
try:
|
|
924
|
+
self._run_validation_tests()
|
|
925
|
+
except ReleaseManagerError as e:
|
|
926
|
+
# Tests failed - cleanup and exit
|
|
927
|
+
self._repo.hgit.checkout("ho-prod")
|
|
928
|
+
self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
|
|
929
|
+
raise ReleaseManagerError(
|
|
930
|
+
f"Tests failed for patch {patch_id}. Not integrated.\n"
|
|
931
|
+
f"The patch breaks the integration with existing patches.\n"
|
|
932
|
+
f"{e}"
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
# 12. Tests passed! Return to ho-prod
|
|
936
|
+
self._repo.hgit.checkout("ho-prod")
|
|
937
|
+
|
|
938
|
+
# 13. Delete temp branch (validation complete, no longer needed)
|
|
939
|
+
self._repo.hgit._HGit__git_repo.git.branch("-D", temp_branch)
|
|
940
|
+
|
|
941
|
+
# 14. Add patch to stage file on ho-prod (file change ONLY)
|
|
942
|
+
self._apply_patch_change_to_stage_file(stage_file, patch_id)
|
|
943
|
+
|
|
944
|
+
# 15. Commit on ho-prod (only release file change)
|
|
945
|
+
commit_msg = f"Add {patch_id} to release {target_version}-stage"
|
|
946
|
+
self._repo.hgit.add(str(self._releases_dir / stage_file))
|
|
947
|
+
self._repo.hgit.commit("-m", commit_msg)
|
|
948
|
+
commit_sha = self._repo.hgit.last_commit()
|
|
949
|
+
|
|
950
|
+
# 16. Push ho-prod (no conflict possible - we have lock)
|
|
951
|
+
self._repo.hgit.push("origin", "ho-prod")
|
|
952
|
+
|
|
953
|
+
# 18. Archive patch branch to ho-release namespace
|
|
954
|
+
archived_branch = f"ho-release/{target_version}/{patch_id}"
|
|
955
|
+
self._repo.hgit.rename_branch(
|
|
956
|
+
f"ho-patch/{patch_id}",
|
|
957
|
+
archived_branch,
|
|
958
|
+
delete_remote_old=True
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
# 19. Read final patch list
|
|
962
|
+
final_patches = self.read_release_patches(stage_file)
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
'status': 'success',
|
|
966
|
+
'patch_id': patch_id,
|
|
967
|
+
'target_version': target_version,
|
|
968
|
+
'stage_file': stage_file,
|
|
969
|
+
'temp_branch': temp_branch,
|
|
970
|
+
'tests_passed': True,
|
|
971
|
+
'archived_branch': archived_branch,
|
|
972
|
+
'commit_sha': commit_sha,
|
|
973
|
+
'patches_in_release': final_patches,
|
|
974
|
+
'lock_tag': lock_tag
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
finally:
|
|
978
|
+
# 20. ALWAYS release lock (even on error)
|
|
979
|
+
self._repo.hgit.release_branch_lock(lock_tag)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _detect_target_stage_file(self, to_version: Optional[str] = None) -> Tuple[str, str]:
|
|
983
|
+
"""
|
|
984
|
+
Detect target stage file (auto-detect or explicit).
|
|
985
|
+
|
|
986
|
+
Logic:
|
|
987
|
+
- If to_version provided: validate it exists
|
|
988
|
+
- If no to_version: auto-detect (error if 0 or multiple stages)
|
|
989
|
+
|
|
990
|
+
Args:
|
|
991
|
+
to_version: Optional explicit version (e.g., "1.3.6")
|
|
992
|
+
|
|
993
|
+
Returns:
|
|
994
|
+
Tuple of (version, filename)
|
|
995
|
+
Example: ("1.3.6", "1.3.6-stage.txt")
|
|
996
|
+
|
|
997
|
+
Raises:
|
|
998
|
+
ReleaseManagerError:
|
|
999
|
+
- No stage release found (need prepare-release first)
|
|
1000
|
+
- Multiple stages without explicit version
|
|
1001
|
+
- Specified stage doesn't exist
|
|
1002
|
+
|
|
1003
|
+
Examples:
|
|
1004
|
+
# Auto-detect (one stage exists)
|
|
1005
|
+
version, filename = self._detect_target_stage_file()
|
|
1006
|
+
# Returns: ("1.3.6", "1.3.6-stage.txt")
|
|
1007
|
+
|
|
1008
|
+
# Explicit version
|
|
1009
|
+
version, filename = self._detect_target_stage_file("1.4.0")
|
|
1010
|
+
# Returns: ("1.4.0", "1.4.0-stage.txt")
|
|
1011
|
+
|
|
1012
|
+
# Error cases
|
|
1013
|
+
# No stage: "No stage release found. Run 'prepare-release' first."
|
|
1014
|
+
# Multiple stages: "Multiple stages found. Use --to-version."
|
|
1015
|
+
# Invalid: "Stage release 1.9.9 not found"
|
|
1016
|
+
"""
|
|
1017
|
+
# Find all stage files
|
|
1018
|
+
stage_files = list(self._releases_dir.glob("*-stage.txt"))
|
|
1019
|
+
|
|
1020
|
+
# Multiple stages: require explicit version
|
|
1021
|
+
if len(stage_files) > 1 and not to_version:
|
|
1022
|
+
versions = sorted([str(self.parse_version_from_filename(f.name)).replace('-stage', '') for f in stage_files])
|
|
1023
|
+
err_msg = "\n".join([f"Multiple stage releases found: {', '.join(versions)}",
|
|
1024
|
+
f"Specify target version:",
|
|
1025
|
+
f" half_orm dev promote-to rc --to-version=<version>"])
|
|
1026
|
+
raise ReleaseManagerError(err_msg)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# If explicit version provided
|
|
1030
|
+
if to_version:
|
|
1031
|
+
stage_file = self._releases_dir / f"{to_version}-stage.txt"
|
|
1032
|
+
|
|
1033
|
+
if not stage_file.exists():
|
|
1034
|
+
raise ReleaseManagerError(
|
|
1035
|
+
f"Stage release {to_version} not found.\n"
|
|
1036
|
+
f"Available stages: {[f.stem for f in stage_files]}"
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
return (to_version, f"{to_version}-stage.txt")
|
|
1040
|
+
|
|
1041
|
+
# Auto-detect
|
|
1042
|
+
if len(stage_files) == 0:
|
|
1043
|
+
raise ReleaseManagerError(
|
|
1044
|
+
"No stage release found.\n"
|
|
1045
|
+
"Run 'half_orm dev prepare-release <type>' first."
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
if len(stage_files) > 1:
|
|
1049
|
+
versions = [f.stem.replace('-stage', '') for f in stage_files]
|
|
1050
|
+
raise ReleaseManagerError(
|
|
1051
|
+
f"Multiple stage releases found: {versions}\n"
|
|
1052
|
+
f"Use --to-version to specify target release."
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
# Single stage file
|
|
1056
|
+
stage_file = stage_files[0]
|
|
1057
|
+
version = stage_file.stem.replace('-stage', '')
|
|
1058
|
+
|
|
1059
|
+
return (version, stage_file.name)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _get_active_patch_branches(self) -> List[str]:
|
|
1063
|
+
"""
|
|
1064
|
+
Get list of all active ho-patch/* branches from remote.
|
|
1065
|
+
|
|
1066
|
+
Reads remote refs after fetch to find all branches matching
|
|
1067
|
+
the ho-patch/* pattern. Used for sending resync notifications.
|
|
1068
|
+
|
|
1069
|
+
Prerequisite: fetch_from_origin() must be called first to have
|
|
1070
|
+
up-to-date remote refs.
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
List of branch names (e.g., ["ho-patch/456-user-auth", "ho-patch/789-security"])
|
|
1074
|
+
Empty list if no patch branches exist
|
|
1075
|
+
|
|
1076
|
+
Examples:
|
|
1077
|
+
# Get active patch branches
|
|
1078
|
+
branches = self._get_active_patch_branches()
|
|
1079
|
+
# Returns: [
|
|
1080
|
+
# "ho-patch/456-user-auth",
|
|
1081
|
+
# "ho-patch/789-security",
|
|
1082
|
+
# "ho-patch/234-reports"
|
|
1083
|
+
# ]
|
|
1084
|
+
|
|
1085
|
+
# Used for notifications
|
|
1086
|
+
for branch in self._get_active_patch_branches():
|
|
1087
|
+
if branch != f"ho-patch/{current_patch_id}":
|
|
1088
|
+
# Send notification to this branch
|
|
1089
|
+
...
|
|
1090
|
+
"""
|
|
1091
|
+
git_repo = self._repo.hgit._HGit__git_repo
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
remote = git_repo.remote('origin')
|
|
1095
|
+
except Exception:
|
|
1096
|
+
return [] # No remote or remote not accessible
|
|
1097
|
+
|
|
1098
|
+
pattern = "origin/ho-patch/*"
|
|
1099
|
+
|
|
1100
|
+
branches = [
|
|
1101
|
+
ref.name.replace('origin/', '', 1)
|
|
1102
|
+
for ref in remote.refs
|
|
1103
|
+
if fnmatch.fnmatch(ref.name, pattern)
|
|
1104
|
+
]
|
|
1105
|
+
|
|
1106
|
+
return branches
|
|
1107
|
+
|
|
1108
|
+
def _send_rebase_notifications(
|
|
1109
|
+
self,
|
|
1110
|
+
version: str,
|
|
1111
|
+
release_type: str,
|
|
1112
|
+
rc_number: int = None) -> List[str]:
|
|
1113
|
+
"""
|
|
1114
|
+
Send merge notifications to all active patch branches.
|
|
1115
|
+
|
|
1116
|
+
After code is merged to ho-prod (promote-to rc or promote-to prod),
|
|
1117
|
+
active development branches must merge changes from ho-prod.
|
|
1118
|
+
This sends notifications (empty commits) to all ho-patch/* branches.
|
|
1119
|
+
|
|
1120
|
+
Note: We use "merge" not "rebase" because branches are shared between
|
|
1121
|
+
developers. Rebase would rewrite history and cause conflicts.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
version: Version string (e.g., "1.3.5")
|
|
1125
|
+
release_type: one of ['alpha', 'beta', 'rc', 'prod']
|
|
1126
|
+
rc_number: RC number (required if release_type != 'prod')
|
|
1127
|
+
|
|
1128
|
+
Returns:
|
|
1129
|
+
List[str]: Notified branch names (without origin/ prefix)
|
|
1130
|
+
|
|
1131
|
+
Examples:
|
|
1132
|
+
# RC promotion
|
|
1133
|
+
notified = mgr._send_rebase_notifications("1.3.5", 'rc', rc_number=1)
|
|
1134
|
+
# → Message: "[ho] 1.3.5-rc1 promoted (MERGE REQUIRED)"
|
|
1135
|
+
|
|
1136
|
+
# Production deployment
|
|
1137
|
+
notified = mgr._send_rebase_notifications("1.3.5", 'prod')
|
|
1138
|
+
# → Message: "[ho] Production 1.3.5 deployed (MERGE REQUIRED)"
|
|
1139
|
+
"""
|
|
1140
|
+
# Get all active patch branches
|
|
1141
|
+
remote_branches = self._repo.hgit.get_remote_branches()
|
|
1142
|
+
|
|
1143
|
+
# Filter for active ho-patch/* branches
|
|
1144
|
+
active_branches = []
|
|
1145
|
+
for branch in remote_branches:
|
|
1146
|
+
# Strip 'origin/' prefix if present
|
|
1147
|
+
branch_name = branch.replace("origin/", "")
|
|
1148
|
+
|
|
1149
|
+
# Only include ho-patch/* branches
|
|
1150
|
+
if branch_name.startswith("ho-patch/"):
|
|
1151
|
+
active_branches.append(branch_name)
|
|
1152
|
+
|
|
1153
|
+
if not active_branches:
|
|
1154
|
+
return []
|
|
1155
|
+
|
|
1156
|
+
notified_branches = []
|
|
1157
|
+
current_branch = self._repo.hgit.branch
|
|
1158
|
+
|
|
1159
|
+
# Build release identifier for message
|
|
1160
|
+
if release_type and release_type != 'prod':
|
|
1161
|
+
if rc_number is None:
|
|
1162
|
+
rc_number = ''
|
|
1163
|
+
release_id = f"{version}-{release_type}{rc_number}"
|
|
1164
|
+
event = "promoted"
|
|
1165
|
+
else: # prod
|
|
1166
|
+
release_id = f"production {version}"
|
|
1167
|
+
event = "deployed"
|
|
1168
|
+
|
|
1169
|
+
for branch in active_branches:
|
|
1170
|
+
try:
|
|
1171
|
+
# Checkout branch
|
|
1172
|
+
self._repo.hgit.checkout(branch)
|
|
1173
|
+
|
|
1174
|
+
# Create notification message
|
|
1175
|
+
message = (
|
|
1176
|
+
f"[ho] {release_id.capitalize()} {event} (MERGE REQUIRED)\n\n"
|
|
1177
|
+
f"Version {release_id} has been {event} with code merged to ho-prod.\n"
|
|
1178
|
+
f"Active patch branches MUST merge these changes.\n\n"
|
|
1179
|
+
f"Action required (branches are shared):\n"
|
|
1180
|
+
f" git checkout {branch}\n"
|
|
1181
|
+
f" git pull # Get this notification\n"
|
|
1182
|
+
f" git merge ho-prod\n"
|
|
1183
|
+
f" # Resolve conflicts if any\n"
|
|
1184
|
+
f" git push\n\n"
|
|
1185
|
+
f"Status: Action required (merge from ho-prod)"
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
# Create empty commit with notification
|
|
1189
|
+
self._repo.hgit.commit("--allow-empty", "-m", message)
|
|
1190
|
+
|
|
1191
|
+
# Push notification
|
|
1192
|
+
self._repo.hgit.push()
|
|
1193
|
+
|
|
1194
|
+
notified_branches.append(branch)
|
|
1195
|
+
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
# Non-blocking: continue with other branches
|
|
1198
|
+
print(f"Warning: Failed to notify {branch}: {e}")
|
|
1199
|
+
continue
|
|
1200
|
+
|
|
1201
|
+
# Return to original branch
|
|
1202
|
+
self._repo.hgit.checkout(current_branch)
|
|
1203
|
+
|
|
1204
|
+
return notified_branches
|
|
1205
|
+
|
|
1206
|
+
def _run_validation_tests(self) -> None:
|
|
1207
|
+
"""
|
|
1208
|
+
Run pytest tests on current branch for validation.
|
|
1209
|
+
|
|
1210
|
+
Executes pytest in tests/ directory and checks return code.
|
|
1211
|
+
Used to validate patch integration on temporary branch before
|
|
1212
|
+
committing to ho-prod.
|
|
1213
|
+
|
|
1214
|
+
Prerequisite: Must be on temp validation branch with patch
|
|
1215
|
+
applied and code generated.
|
|
1216
|
+
|
|
1217
|
+
Raises:
|
|
1218
|
+
ReleaseManagerError: If tests fail (non-zero exit code)
|
|
1219
|
+
Error message includes pytest output for debugging
|
|
1220
|
+
|
|
1221
|
+
Examples:
|
|
1222
|
+
# On temp-valid-1.3.6 after applying patches
|
|
1223
|
+
try:
|
|
1224
|
+
self._run_validation_tests()
|
|
1225
|
+
print("✅ All tests passed")
|
|
1226
|
+
except ReleaseManagerError as e:
|
|
1227
|
+
print(f"❌ Tests failed:\n{e}")
|
|
1228
|
+
# Cleanup and exit
|
|
1229
|
+
"""
|
|
1230
|
+
try:
|
|
1231
|
+
result = subprocess.run(
|
|
1232
|
+
["pytest", "tests/"],
|
|
1233
|
+
cwd=str(self._repo.base_dir),
|
|
1234
|
+
capture_output=True,
|
|
1235
|
+
text=True
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
if result.returncode != 0:
|
|
1239
|
+
raise ReleaseManagerError(
|
|
1240
|
+
f"Tests failed for patch integration:\n"
|
|
1241
|
+
f"{result.stdout}\n"
|
|
1242
|
+
f"{result.stderr}"
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
except FileNotFoundError:
|
|
1246
|
+
raise ReleaseManagerError(
|
|
1247
|
+
"pytest not found. Install pytest to run validation tests."
|
|
1248
|
+
)
|
|
1249
|
+
except subprocess.TimeoutExpired:
|
|
1250
|
+
raise ReleaseManagerError(
|
|
1251
|
+
"Tests timed out. Check for hanging tests."
|
|
1252
|
+
)
|
|
1253
|
+
except Exception as e:
|
|
1254
|
+
raise ReleaseManagerError(
|
|
1255
|
+
f"Failed to run tests: {e}"
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _apply_patch_change_to_stage_file(
|
|
1261
|
+
self,
|
|
1262
|
+
stage_file: str,
|
|
1263
|
+
patch_id: str
|
|
1264
|
+
) -> None:
|
|
1265
|
+
"""
|
|
1266
|
+
Add patch ID to stage release file (append to end).
|
|
1267
|
+
|
|
1268
|
+
Appends patch_id as new line at end of releases/{stage_file}.
|
|
1269
|
+
Creates file if it doesn't exist (should not happen in normal flow).
|
|
1270
|
+
|
|
1271
|
+
Does NOT commit - caller is responsible for staging and committing.
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
stage_file: Stage filename (e.g., "1.3.6-stage.txt")
|
|
1275
|
+
patch_id: Patch identifier to add (e.g., "456-user-auth")
|
|
1276
|
+
|
|
1277
|
+
Raises:
|
|
1278
|
+
ReleaseManagerError: If file write fails
|
|
1279
|
+
|
|
1280
|
+
Examples:
|
|
1281
|
+
# Add patch to stage file
|
|
1282
|
+
self._apply_patch_change_to_stage_file("1.3.6-stage.txt", "456-user-auth")
|
|
1283
|
+
|
|
1284
|
+
# File content before:
|
|
1285
|
+
# 123-initial
|
|
1286
|
+
# 789-security
|
|
1287
|
+
|
|
1288
|
+
# File content after:
|
|
1289
|
+
# 123-initial
|
|
1290
|
+
# 789-security
|
|
1291
|
+
# 456-user-auth
|
|
1292
|
+
|
|
1293
|
+
# Caller must then:
|
|
1294
|
+
# self._repo.hgit.add("releases/1.3.6-stage.txt")
|
|
1295
|
+
# self._repo.hgit.commit("-m", "Add 456-user-auth to release")
|
|
1296
|
+
"""
|
|
1297
|
+
stage_path = self._releases_dir / stage_file
|
|
1298
|
+
|
|
1299
|
+
try:
|
|
1300
|
+
# Append patch to file (create if doesn't exist)
|
|
1301
|
+
with open(stage_path, 'a', encoding='utf-8') as f:
|
|
1302
|
+
f.write(f"{patch_id}\n")
|
|
1303
|
+
|
|
1304
|
+
except Exception as e:
|
|
1305
|
+
raise ReleaseManagerError(
|
|
1306
|
+
f"Failed to update stage file {stage_file}: {e}"
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
def promote_to(self, target: str) -> dict:
|
|
1310
|
+
"""
|
|
1311
|
+
Unified promotion workflow for RC and production releases.
|
|
1312
|
+
|
|
1313
|
+
Handles promotion of stage releases to either RC or production with
|
|
1314
|
+
shared logic for validations, lock management, code merging, branch
|
|
1315
|
+
cleanup, and notifications. Target-specific operations (RC numbering,
|
|
1316
|
+
schema generation) are conditionally executed.
|
|
1317
|
+
|
|
1318
|
+
Args:
|
|
1319
|
+
target: Either 'rc' or 'prod'
|
|
1320
|
+
- 'rc': Promotes stage to RC (rc1, rc2, etc.)
|
|
1321
|
+
- 'prod': Promotes stage (or empty) to production
|
|
1322
|
+
|
|
1323
|
+
Returns:
|
|
1324
|
+
dict: Promotion result with target-specific fields
|
|
1325
|
+
|
|
1326
|
+
Common fields:
|
|
1327
|
+
'status': 'success'
|
|
1328
|
+
'version': str (e.g., "1.3.5")
|
|
1329
|
+
'from_file': str or None (source filename)
|
|
1330
|
+
'to_file': str (target filename)
|
|
1331
|
+
'patches_merged': List[str] (merged patch IDs)
|
|
1332
|
+
'branches_deleted': List[str] (deleted branch names)
|
|
1333
|
+
'commit_sha': str
|
|
1334
|
+
'notifications_sent': List[str] (notified branches)
|
|
1335
|
+
'lock_tag': str
|
|
1336
|
+
|
|
1337
|
+
RC-specific fields (target='rc'):
|
|
1338
|
+
'rc_number': int (e.g., 1, 2, 3)
|
|
1339
|
+
'code_merged': bool (always True)
|
|
1340
|
+
|
|
1341
|
+
Production-specific fields (target='prod'):
|
|
1342
|
+
'patches_applied': List[str] (all patches applied to DB)
|
|
1343
|
+
'schema_file': Path (model/schema-X.Y.Z.sql)
|
|
1344
|
+
'metadata_file': Path (model/metadata-X.Y.Z.sql)
|
|
1345
|
+
|
|
1346
|
+
Raises:
|
|
1347
|
+
ReleaseManagerError: For validation failures, lock errors, etc.
|
|
1348
|
+
ValueError: If target is not 'rc' or 'prod'
|
|
1349
|
+
|
|
1350
|
+
Workflow:
|
|
1351
|
+
0. restore prod database (schema & metadata)
|
|
1352
|
+
1. Pre-lock validations (ho-prod branch, clean repo)
|
|
1353
|
+
2. Detect source and target (version-specific logic)
|
|
1354
|
+
3. ACQUIRE DISTRIBUTED LOCK (30min timeout)
|
|
1355
|
+
4. Fetch + sync with origin
|
|
1356
|
+
5. [PROD ONLY] Restore DB and apply all patches
|
|
1357
|
+
6. Merge archived patch code to ho-prod
|
|
1358
|
+
7. Create target release file (mv or create)
|
|
1359
|
+
8. [PROD ONLY] Generate schema + metadata + symlink
|
|
1360
|
+
9. Commit + push
|
|
1361
|
+
10. Push to origin
|
|
1362
|
+
10.5 Create new empty stage file
|
|
1363
|
+
11. Send rebase notifications
|
|
1364
|
+
12. Cleanup patch branches
|
|
1365
|
+
13. RELEASE LOCK (always, even on error)
|
|
1366
|
+
|
|
1367
|
+
Examples:
|
|
1368
|
+
# Promote to RC
|
|
1369
|
+
result = mgr.promote_to(target='rc')
|
|
1370
|
+
# → Creates X.Y.Z-rc2.txt from X.Y.Z-stage.txt
|
|
1371
|
+
# → Merges code, cleans branches, sends notifications
|
|
1372
|
+
|
|
1373
|
+
# Promote to production
|
|
1374
|
+
result = mgr.promote_to(target='prod')
|
|
1375
|
+
# → Creates X.Y.Z.txt from X.Y.Z-stage.txt (or empty)
|
|
1376
|
+
# → Applies all patches to DB
|
|
1377
|
+
# → Generates schema-X.Y.Z.sql + metadata-X.Y.Z.sql
|
|
1378
|
+
# → Merges code, cleans branches, sends notifications
|
|
1379
|
+
"""
|
|
1380
|
+
# Validate target parameter
|
|
1381
|
+
if target not in ('alpha', 'beta', 'rc', 'prod'):
|
|
1382
|
+
raise ValueError(f"Invalid target: {target}. Must be in ['alpha', 'beta', 'rc', 'prod']")
|
|
1383
|
+
|
|
1384
|
+
# 0. restore database to prod
|
|
1385
|
+
self._repo.restore_database_from_schema()
|
|
1386
|
+
|
|
1387
|
+
# 1. Pre-lock validations (common)
|
|
1388
|
+
if self._repo.hgit.branch != "ho-prod":
|
|
1389
|
+
raise ReleaseManagerError(
|
|
1390
|
+
"Must be on ho-prod branch to promote release. "
|
|
1391
|
+
f"Current branch: {self._repo.hgit.branch}"
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
if not self._repo.hgit.repos_is_clean():
|
|
1395
|
+
raise ReleaseManagerError(
|
|
1396
|
+
"Repository has uncommitted changes. "
|
|
1397
|
+
"Commit or stash changes before promoting."
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
# 2. Detect source and target (target-specific)
|
|
1401
|
+
version, stage_file = self._detect_stage_to_promote()
|
|
1402
|
+
if target != 'prod':
|
|
1403
|
+
# RC: stage required, validate single active RC rule
|
|
1404
|
+
self._validate_single_active_rc(version)
|
|
1405
|
+
rc_number = self._determine_rc_number(version)
|
|
1406
|
+
target_file = f"{version}-{target}{rc_number}.txt"
|
|
1407
|
+
source_type = 'stage'
|
|
1408
|
+
else: # target == 'prod'
|
|
1409
|
+
# Production: stage optional, sequential version
|
|
1410
|
+
stage_path = Path(self._releases_dir) / f"{version}-stage.txt"
|
|
1411
|
+
if stage_path.exists():
|
|
1412
|
+
stage_file = f"{version}-stage.txt"
|
|
1413
|
+
source_type = 'stage'
|
|
1414
|
+
else:
|
|
1415
|
+
stage_file = None
|
|
1416
|
+
source_type = 'empty'
|
|
1417
|
+
target_file = f"{version}.txt"
|
|
1418
|
+
|
|
1419
|
+
# 3. Acquire distributed lock
|
|
1420
|
+
lock_tag = None
|
|
1421
|
+
try:
|
|
1422
|
+
lock_tag = self._repo.hgit.acquire_branch_lock("ho-prod", timeout_minutes=30)
|
|
1423
|
+
|
|
1424
|
+
# 4. Fetch from origin and sync
|
|
1425
|
+
self._repo.hgit.fetch_from_origin()
|
|
1426
|
+
|
|
1427
|
+
is_synced, sync_status = self._repo.hgit.is_branch_synced("ho-prod")
|
|
1428
|
+
if not is_synced:
|
|
1429
|
+
if sync_status == "behind":
|
|
1430
|
+
self._repo.hgit.pull()
|
|
1431
|
+
elif sync_status == "diverged":
|
|
1432
|
+
raise ReleaseManagerError(
|
|
1433
|
+
"ho-prod has diverged from origin. "
|
|
1434
|
+
"Resolve conflicts manually: git pull origin ho-prod"
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
# 5. Apply patches to database (prod only)
|
|
1438
|
+
patches_applied = []
|
|
1439
|
+
if target == 'prod':
|
|
1440
|
+
patches_applied = self._restore_and_apply_all_patches(version)
|
|
1441
|
+
|
|
1442
|
+
# 6. Merge archived patches code into ho-prod (common)
|
|
1443
|
+
if stage_file:
|
|
1444
|
+
patches_merged = self._merge_archived_patches_to_ho_prod(version, stage_file)
|
|
1445
|
+
else:
|
|
1446
|
+
patches_merged = []
|
|
1447
|
+
|
|
1448
|
+
# 7. Create target release file
|
|
1449
|
+
stage_path = self._releases_dir / stage_file if stage_file else None
|
|
1450
|
+
target_path = self._releases_dir / target_file
|
|
1451
|
+
|
|
1452
|
+
if stage_file:
|
|
1453
|
+
# Rename stage to target (git mv)
|
|
1454
|
+
self._repo.hgit.mv(str(stage_path), str(target_path))
|
|
1455
|
+
else:
|
|
1456
|
+
# Create empty production file (prod only)
|
|
1457
|
+
target_path.touch()
|
|
1458
|
+
self._repo.hgit.add(str(target_path))
|
|
1459
|
+
|
|
1460
|
+
# 8. Generate schema and metadata (prod only)
|
|
1461
|
+
schema_info = {}
|
|
1462
|
+
if target == 'prod':
|
|
1463
|
+
schema_info = self._generate_schema_and_metadata(version)
|
|
1464
|
+
# Add generated files to commit
|
|
1465
|
+
self._repo.hgit.add(str(schema_info['schema_file']))
|
|
1466
|
+
self._repo.hgit.add(str(schema_info['metadata_file']))
|
|
1467
|
+
self._repo.hgit.add(str(self._repo.base_dir / Path("model") / "schema.sql"))
|
|
1468
|
+
|
|
1469
|
+
# 9. Commit promotion
|
|
1470
|
+
full_version = version
|
|
1471
|
+
if target != 'prod':
|
|
1472
|
+
full_version = f"{version}-rc{rc_number}"
|
|
1473
|
+
commit_message = f"Promote {version}-stage to {full_version}"
|
|
1474
|
+
else:
|
|
1475
|
+
commit_message = f"Promote {version}-stage to production release {version}"
|
|
1476
|
+
|
|
1477
|
+
self._repo.hgit.add(str(target_path))
|
|
1478
|
+
self._repo.hgit.commit("-m", commit_message)
|
|
1479
|
+
commit_sha = self._repo.hgit.last_commit()
|
|
1480
|
+
|
|
1481
|
+
# 10. Push to origin
|
|
1482
|
+
self._repo.hgit.push()
|
|
1483
|
+
# Create and push Git tag for new release
|
|
1484
|
+
tag_name = f"v{full_version}"
|
|
1485
|
+
self._repo.hgit.create_tag(tag_name, f"Release {full_version}")
|
|
1486
|
+
self._repo.hgit.push_tag(tag_name)
|
|
1487
|
+
|
|
1488
|
+
# 10.5 Create new empty stage file ONLY for RC promotion
|
|
1489
|
+
new_stage_filename = None
|
|
1490
|
+
new_stage_commit_sha = None
|
|
1491
|
+
|
|
1492
|
+
if target != 'prod':
|
|
1493
|
+
# Pour RC : on peut continuer à travailler sur la même version (rc2, rc3...)
|
|
1494
|
+
new_stage_filename = f"{version}-stage.txt"
|
|
1495
|
+
new_stage_path = self._releases_dir / new_stage_filename
|
|
1496
|
+
new_stage_path.write_text("")
|
|
1497
|
+
|
|
1498
|
+
# Add + commit + push
|
|
1499
|
+
self._repo.hgit.add(new_stage_path)
|
|
1500
|
+
commit_msg = f"Create new empty stage file for {version}"
|
|
1501
|
+
new_stage_commit_sha = self._repo.hgit.commit('-m', commit_msg)
|
|
1502
|
+
self._repo.hgit.push()
|
|
1503
|
+
|
|
1504
|
+
# 11. Send rebase notifications to active branches
|
|
1505
|
+
if target != 'prod':
|
|
1506
|
+
notifications_sent = self._send_rebase_notifications(version, target, rc_number=rc_number)
|
|
1507
|
+
else:
|
|
1508
|
+
notifications_sent = self._send_rebase_notifications(version, target)
|
|
1509
|
+
|
|
1510
|
+
# 12. Cleanup patch branches
|
|
1511
|
+
if stage_file:
|
|
1512
|
+
branches_deleted = self._cleanup_patch_branches(version, stage_file)
|
|
1513
|
+
else:
|
|
1514
|
+
branches_deleted = []
|
|
1515
|
+
|
|
1516
|
+
# Build result dict (common fields)
|
|
1517
|
+
result = {
|
|
1518
|
+
'status': 'success',
|
|
1519
|
+
'version': version,
|
|
1520
|
+
'from_file': stage_file,
|
|
1521
|
+
'to_file': target_file,
|
|
1522
|
+
'patches_merged': patches_merged,
|
|
1523
|
+
'branches_deleted': branches_deleted,
|
|
1524
|
+
'commit_sha': commit_sha,
|
|
1525
|
+
'notifications_sent': notifications_sent,
|
|
1526
|
+
'lock_tag': lock_tag,
|
|
1527
|
+
'new_stage_created': new_stage_filename,
|
|
1528
|
+
'tag_name': tag_name
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
# Add target-specific fields
|
|
1532
|
+
if target != 'prod':
|
|
1533
|
+
result['rc_number'] = rc_number
|
|
1534
|
+
result['code_merged'] = True
|
|
1535
|
+
else:
|
|
1536
|
+
result['source_type'] = source_type
|
|
1537
|
+
result['patches_applied'] = patches_applied
|
|
1538
|
+
result.update(schema_info)
|
|
1539
|
+
|
|
1540
|
+
return result
|
|
1541
|
+
|
|
1542
|
+
finally:
|
|
1543
|
+
# 13. Always release lock (even on error)
|
|
1544
|
+
if lock_tag:
|
|
1545
|
+
self._repo.hgit.release_branch_lock(lock_tag)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _get_next_production_version(self) -> str:
|
|
1549
|
+
"""
|
|
1550
|
+
Get next sequential production version.
|
|
1551
|
+
|
|
1552
|
+
Calculates the next patch version after current production.
|
|
1553
|
+
Used by promote-to prod to determine target version.
|
|
1554
|
+
|
|
1555
|
+
Returns:
|
|
1556
|
+
str: Next version (e.g., "1.3.5" if current is "1.3.4")
|
|
1557
|
+
|
|
1558
|
+
Raises:
|
|
1559
|
+
ReleaseManagerError: If cannot determine production version
|
|
1560
|
+
|
|
1561
|
+
Examples:
|
|
1562
|
+
# Current production: 1.3.4
|
|
1563
|
+
next_ver = mgr._get_next_production_version()
|
|
1564
|
+
# → "1.3.5"
|
|
1565
|
+
"""
|
|
1566
|
+
rc_files = list(self._releases_dir.glob("*-rc*.txt"))
|
|
1567
|
+
|
|
1568
|
+
if rc_files:
|
|
1569
|
+
# Use version from RC (without -rcN suffix)
|
|
1570
|
+
# There should be only one due to single active RC rule
|
|
1571
|
+
rc_file = rc_files[0]
|
|
1572
|
+
version = self.parse_version_from_filename(rc_file.name)
|
|
1573
|
+
return re.sub('-.*', '', str(version))
|
|
1574
|
+
|
|
1575
|
+
# No RC exists: increment from current production
|
|
1576
|
+
# This handles edge case of direct prod promotion without RC
|
|
1577
|
+
current_prod = self._get_production_version()
|
|
1578
|
+
current_version = self.parse_version_from_filename(f"{current_prod}.txt")
|
|
1579
|
+
return self.calculate_next_version(current_version, 'patch')
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _restore_and_apply_all_patches(self, version: str) -> List[str]:
|
|
1583
|
+
"""
|
|
1584
|
+
Restore database and apply all patches sequentially.
|
|
1585
|
+
|
|
1586
|
+
Used by promote-to prod to prepare database before schema dump.
|
|
1587
|
+
Restores DB from current schema.sql, then applies all patches
|
|
1588
|
+
from RC files and stage file in order.
|
|
1589
|
+
|
|
1590
|
+
Args:
|
|
1591
|
+
version: Target version (e.g., "1.3.5")
|
|
1592
|
+
|
|
1593
|
+
Returns:
|
|
1594
|
+
List[str]: Patch IDs applied (in order)
|
|
1595
|
+
|
|
1596
|
+
Examples:
|
|
1597
|
+
# Files: 1.3.5-rc1.txt [10], 1.3.5-rc2.txt [42, 12], 1.3.5-stage.txt [18]
|
|
1598
|
+
patches = mgr._restore_and_apply_all_patches("1.3.5")
|
|
1599
|
+
# → Returns ["10", "42", "12", "18"]
|
|
1600
|
+
# → Database now at state with all patches applied
|
|
1601
|
+
"""
|
|
1602
|
+
# 1. Restore database to current production state
|
|
1603
|
+
self._repo.restore_database_from_schema()
|
|
1604
|
+
|
|
1605
|
+
# 2. Get all patches for this version (RC1 + RC2 + ... + stage)
|
|
1606
|
+
all_patches = self.get_all_release_context_patches()
|
|
1607
|
+
|
|
1608
|
+
# 3. Apply each patch sequentially
|
|
1609
|
+
for patch_id in all_patches:
|
|
1610
|
+
self._repo.patch_manager.apply_patch_files(patch_id, self._repo.model)
|
|
1611
|
+
|
|
1612
|
+
# 4. Update database version to target production version
|
|
1613
|
+
# CRITICAL: Must be done before generating schema dumps
|
|
1614
|
+
self._repo.database.register_release(*version.split('.'))
|
|
1615
|
+
|
|
1616
|
+
return all_patches
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _generate_schema_and_metadata(self, version: str) -> dict:
|
|
1620
|
+
"""
|
|
1621
|
+
Generate schema and metadata dumps, update symlink.
|
|
1622
|
+
|
|
1623
|
+
Generates schema-X.Y.Z.sql and metadata-X.Y.Z.sql files via pg_dump,
|
|
1624
|
+
then updates schema.sql symlink to point to new version.
|
|
1625
|
+
|
|
1626
|
+
Args:
|
|
1627
|
+
version: Version string (e.g., "1.3.5")
|
|
1628
|
+
|
|
1629
|
+
Returns:
|
|
1630
|
+
dict: Generated file paths
|
|
1631
|
+
'schema_file': Path to schema-X.Y.Z.sql
|
|
1632
|
+
'metadata_file': Path to metadata-X.Y.Z.sql
|
|
1633
|
+
|
|
1634
|
+
Raises:
|
|
1635
|
+
Exception: If pg_dump fails or file operations fail
|
|
1636
|
+
|
|
1637
|
+
Examples:
|
|
1638
|
+
info = mgr._generate_schema_and_metadata("1.3.5")
|
|
1639
|
+
# → Creates model/schema-1.3.5.sql
|
|
1640
|
+
# → Creates model/metadata-1.3.5.sql
|
|
1641
|
+
# → Updates model/schema.sql → schema-1.3.5.sql
|
|
1642
|
+
# → Returns {'schema_file': Path(...), 'metadata_file': Path(...)}
|
|
1643
|
+
"""
|
|
1644
|
+
from half_orm_dev.database import Database
|
|
1645
|
+
|
|
1646
|
+
model_dir = Path(self._repo.base_dir) / "model"
|
|
1647
|
+
|
|
1648
|
+
# Database._generate_schema_sql() creates both schema and metadata
|
|
1649
|
+
schema_file = Database._generate_schema_sql(
|
|
1650
|
+
self._repo.database,
|
|
1651
|
+
version,
|
|
1652
|
+
model_dir
|
|
1653
|
+
)
|
|
1654
|
+
metadata_file = model_dir / f"metadata-{version}.sql"
|
|
1655
|
+
|
|
1656
|
+
return {
|
|
1657
|
+
'schema_file': schema_file,
|
|
1658
|
+
'metadata_file': metadata_file
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def _detect_stage_to_promote(self) -> Tuple[str, str]:
|
|
1663
|
+
"""
|
|
1664
|
+
Detect smallest stage release to promote.
|
|
1665
|
+
|
|
1666
|
+
Finds all *-stage.txt files, parses versions, and returns the smallest
|
|
1667
|
+
version. This ensures sequential promotion (cannot skip versions).
|
|
1668
|
+
|
|
1669
|
+
Algorithm:
|
|
1670
|
+
1. List all releases/*-stage.txt files
|
|
1671
|
+
2. Parse version from each filename (e.g., "1.3.5-stage.txt" → "1.3.5")
|
|
1672
|
+
3. Sort versions in ascending order
|
|
1673
|
+
4. Return smallest version and filename
|
|
1674
|
+
|
|
1675
|
+
Returns:
|
|
1676
|
+
Tuple of (version, stage_filename)
|
|
1677
|
+
Example: ("1.3.5", "1.3.5-stage.txt")
|
|
1678
|
+
|
|
1679
|
+
Raises:
|
|
1680
|
+
ReleaseManagerError: If no stage releases found
|
|
1681
|
+
|
|
1682
|
+
Examples:
|
|
1683
|
+
# Single stage
|
|
1684
|
+
releases/1.3.5-stage.txt exists
|
|
1685
|
+
version, filename = mgr._detect_stage_to_promote()
|
|
1686
|
+
# → ("1.3.5", "1.3.5-stage.txt")
|
|
1687
|
+
|
|
1688
|
+
# Multiple stages (returns smallest)
|
|
1689
|
+
releases/1.3.5-stage.txt, 1.4.0-stage.txt, 2.0.0-stage.txt exist
|
|
1690
|
+
version, filename = mgr._detect_stage_to_promote()
|
|
1691
|
+
# → ("1.3.5", "1.3.5-stage.txt")
|
|
1692
|
+
|
|
1693
|
+
# No stages
|
|
1694
|
+
version, filename = mgr._detect_stage_to_promote()
|
|
1695
|
+
# → Raises: "No stage releases found. Create one with prepare-release"
|
|
1696
|
+
"""
|
|
1697
|
+
# List all stage files
|
|
1698
|
+
stage_files = list(self._releases_dir.glob("*-stage.txt"))
|
|
1699
|
+
|
|
1700
|
+
if not stage_files:
|
|
1701
|
+
raise ReleaseManagerError(
|
|
1702
|
+
"No stage releases found. "
|
|
1703
|
+
"Create a stage release first with: half_orm dev prepare-release"
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
# Parse versions and sort
|
|
1707
|
+
stage_versions = []
|
|
1708
|
+
for stage_file in stage_files:
|
|
1709
|
+
# Extract version from filename (e.g., "1.3.5-stage.txt" → "1.3.5")
|
|
1710
|
+
version_str = stage_file.name.replace("-stage.txt", "")
|
|
1711
|
+
version = self.parse_version_from_filename(stage_file.name)
|
|
1712
|
+
stage_versions.append((version, version_str, stage_file.name))
|
|
1713
|
+
|
|
1714
|
+
# Sort by version (ascending)
|
|
1715
|
+
stage_versions.sort(key=lambda x: (x[0].major, x[0].minor, x[0].patch))
|
|
1716
|
+
|
|
1717
|
+
# Return smallest version
|
|
1718
|
+
smallest = stage_versions[0]
|
|
1719
|
+
return smallest[1], smallest[2] # (version_str, filename)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _validate_single_active_rc(self, stage_version: str) -> None:
|
|
1723
|
+
"""
|
|
1724
|
+
Validate single active RC rule.
|
|
1725
|
+
|
|
1726
|
+
Ensures only one version level is in RC at a time. The rule allows:
|
|
1727
|
+
- No RC exists → OK (promoting first RC)
|
|
1728
|
+
- RC of SAME version exists → OK (rc1 → rc2 → rc3)
|
|
1729
|
+
- RC of DIFFERENT version exists → ERROR (must deploy first)
|
|
1730
|
+
|
|
1731
|
+
Args:
|
|
1732
|
+
stage_version: Version being promoted (e.g., "1.3.5")
|
|
1733
|
+
|
|
1734
|
+
Raises:
|
|
1735
|
+
ReleaseManagerError: If different version RC exists
|
|
1736
|
+
|
|
1737
|
+
Examples:
|
|
1738
|
+
# No RC exists - OK
|
|
1739
|
+
stage_version = "1.3.5"
|
|
1740
|
+
mgr._validate_single_active_rc(stage_version)
|
|
1741
|
+
# → No error
|
|
1742
|
+
|
|
1743
|
+
# Same version RC exists - OK
|
|
1744
|
+
releases/1.3.5-rc1.txt exists
|
|
1745
|
+
stage_version = "1.3.5"
|
|
1746
|
+
mgr._validate_single_active_rc(stage_version)
|
|
1747
|
+
# → No error (promoting to rc2)
|
|
1748
|
+
|
|
1749
|
+
# Different version RC exists - ERROR
|
|
1750
|
+
releases/1.3.5-rc1.txt exists
|
|
1751
|
+
stage_version = "1.4.0"
|
|
1752
|
+
mgr._validate_single_active_rc(stage_version)
|
|
1753
|
+
# → Raises: "Cannot promote 1.4.0-stage, RC 1.3.5-rc1 must be deployed first"
|
|
1754
|
+
|
|
1755
|
+
# Multiple RCs of same version - OK
|
|
1756
|
+
releases/1.3.5-rc1.txt, 1.3.5-rc2.txt exist
|
|
1757
|
+
stage_version = "1.3.5"
|
|
1758
|
+
mgr._validate_single_active_rc(stage_version)
|
|
1759
|
+
# → No error (promoting to rc3)
|
|
1760
|
+
"""
|
|
1761
|
+
# List all RC files
|
|
1762
|
+
rc_files = list(self._releases_dir.glob("*-rc*.txt"))
|
|
1763
|
+
|
|
1764
|
+
if not rc_files:
|
|
1765
|
+
# No RC exists, promotion allowed
|
|
1766
|
+
return
|
|
1767
|
+
|
|
1768
|
+
# Check if any RC is of a different version
|
|
1769
|
+
for rc_file in rc_files:
|
|
1770
|
+
# Extract version from RC filename (e.g., "1.3.5-rc1.txt" → "1.3.5")
|
|
1771
|
+
rc_filename = rc_file.name
|
|
1772
|
+
# Remove "-rcN.txt" suffix to get version
|
|
1773
|
+
rc_version = rc_filename.split("-rc")[0]
|
|
1774
|
+
|
|
1775
|
+
if rc_version != stage_version:
|
|
1776
|
+
# Different version RC exists, block promotion
|
|
1777
|
+
raise ReleaseManagerError(
|
|
1778
|
+
f"Cannot promote {stage_version}-stage to RC: "
|
|
1779
|
+
f"RC {rc_filename.replace('.txt', '')} must be deployed to production first. "
|
|
1780
|
+
f"Only one version can be in RC at a time."
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
# All RCs are same version as stage, promotion allowed
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def _determine_rc_number(self, version: str) -> int:
|
|
1787
|
+
"""
|
|
1788
|
+
Determine next RC number for version.
|
|
1789
|
+
|
|
1790
|
+
Finds all existing RC files for the version and returns next number.
|
|
1791
|
+
If no RCs exist, returns 1. If rc1, rc2 exist, returns 3.
|
|
1792
|
+
|
|
1793
|
+
Args:
|
|
1794
|
+
version: Version string (e.g., "1.3.5")
|
|
1795
|
+
|
|
1796
|
+
Returns:
|
|
1797
|
+
Next RC number (1, 2, 3, etc.)
|
|
1798
|
+
|
|
1799
|
+
Examples:
|
|
1800
|
+
# No existing RCs
|
|
1801
|
+
version = "1.3.5"
|
|
1802
|
+
rc_num = mgr._determine_rc_number(version)
|
|
1803
|
+
# → 1
|
|
1804
|
+
|
|
1805
|
+
# rc1 exists
|
|
1806
|
+
releases/1.3.5-rc1.txt exists
|
|
1807
|
+
rc_num = mgr._determine_rc_number(version)
|
|
1808
|
+
# → 2
|
|
1809
|
+
|
|
1810
|
+
# rc1, rc2, rc3 exist
|
|
1811
|
+
releases/1.3.5-rc1.txt, 1.3.5-rc2.txt, 1.3.5-rc3.txt exist
|
|
1812
|
+
rc_num = mgr._determine_rc_number(version)
|
|
1813
|
+
# → 4
|
|
1814
|
+
|
|
1815
|
+
Note:
|
|
1816
|
+
Uses get_rc_files() which returns sorted RC files for version.
|
|
1817
|
+
"""
|
|
1818
|
+
# Use existing get_rc_files() method which returns sorted list
|
|
1819
|
+
rc_files = self.get_rc_files(version)
|
|
1820
|
+
|
|
1821
|
+
if not rc_files:
|
|
1822
|
+
# No RCs exist, this will be rc1
|
|
1823
|
+
return 1
|
|
1824
|
+
|
|
1825
|
+
# get_rc_files() returns sorted list, so last file has highest number
|
|
1826
|
+
# Extract RC number from last filename (e.g., "1.3.5-rc3.txt" → 3)
|
|
1827
|
+
last_rc_file = rc_files[-1].name
|
|
1828
|
+
|
|
1829
|
+
# Extract number after "-rc" (e.g., "1.3.5-rc3.txt" → "3")
|
|
1830
|
+
match = re.search(r'-rc(\d+)\.txt', last_rc_file)
|
|
1831
|
+
if match:
|
|
1832
|
+
last_rc_num = int(match.group(1))
|
|
1833
|
+
return last_rc_num + 1
|
|
1834
|
+
|
|
1835
|
+
# Fallback (shouldn't happen with valid RC files)
|
|
1836
|
+
return len(rc_files) + 1
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _merge_archived_patches_to_ho_prod(self, version: str, stage_file: str) -> List[str]:
|
|
1840
|
+
"""
|
|
1841
|
+
Merge all archived patch branches code into ho-prod.
|
|
1842
|
+
|
|
1843
|
+
THIS IS WHERE CODE ENTERS HO-PROD. During add-to-release, patches
|
|
1844
|
+
are archived to ho-release/X.Y.Z/patch-id but code stays separate.
|
|
1845
|
+
At promote_to, all archived patches are merged into ho-prod.
|
|
1846
|
+
|
|
1847
|
+
Algorithm:
|
|
1848
|
+
1. Read patch list from stage file (e.g., releases/1.3.5-stage.txt)
|
|
1849
|
+
2. For each patch in list:
|
|
1850
|
+
- Check if archived branch exists: ho-release/{version}/{patch_id}
|
|
1851
|
+
- If exists: git merge ho-release/{version}/{patch_id}
|
|
1852
|
+
- Handle merge conflicts (abort and raise error)
|
|
1853
|
+
3. Return list of merged patches
|
|
1854
|
+
|
|
1855
|
+
Args:
|
|
1856
|
+
version: Version string (e.g., "1.3.5")
|
|
1857
|
+
stage_file: Stage filename (e.g., "1.3.5-stage.txt")
|
|
1858
|
+
|
|
1859
|
+
Returns:
|
|
1860
|
+
List of merged patch IDs
|
|
1861
|
+
|
|
1862
|
+
Raises:
|
|
1863
|
+
ReleaseManagerError: If merge conflicts occur or branch not found
|
|
1864
|
+
|
|
1865
|
+
Examples:
|
|
1866
|
+
# Successful merge
|
|
1867
|
+
releases/1.3.5-stage.txt contains: ["456-user-auth", "789-security"]
|
|
1868
|
+
ho-release/1.3.5/456-user-auth exists
|
|
1869
|
+
ho-release/1.3.5/789-security exists
|
|
1870
|
+
|
|
1871
|
+
patches = mgr._merge_archived_patches_to_ho_prod("1.3.5", "1.3.5-stage.txt")
|
|
1872
|
+
# → ["456-user-auth", "789-security"]
|
|
1873
|
+
# → Both branches merged into ho-prod
|
|
1874
|
+
# → Code now in ho-prod
|
|
1875
|
+
|
|
1876
|
+
# Merge conflict
|
|
1877
|
+
Patch code conflicts with existing ho-prod code
|
|
1878
|
+
patches = mgr._merge_archived_patches_to_ho_prod("1.3.5", "1.3.5-stage.txt")
|
|
1879
|
+
# → Raises: "Merge conflict with patch 456-user-auth, resolve manually"
|
|
1880
|
+
|
|
1881
|
+
# Missing archived branch
|
|
1882
|
+
releases/1.3.5-stage.txt contains: ["456-user-auth"]
|
|
1883
|
+
ho-release/1.3.5/456-user-auth does NOT exist
|
|
1884
|
+
|
|
1885
|
+
patches = mgr._merge_archived_patches_to_ho_prod("1.3.5", "1.3.5-stage.txt")
|
|
1886
|
+
# → Raises: "Archived branch not found: ho-release/1.3.5/456-user-auth"
|
|
1887
|
+
|
|
1888
|
+
Note:
|
|
1889
|
+
After this operation, ho-prod contains both metadata (releases/*.txt)
|
|
1890
|
+
and code (merged from ho-release/X.Y.Z/*). This is the key difference
|
|
1891
|
+
between stage (metadata only) and RC (metadata + code).
|
|
1892
|
+
"""
|
|
1893
|
+
# Read patch list from stage file using existing method
|
|
1894
|
+
patch_ids = self.read_release_patches(stage_file)
|
|
1895
|
+
|
|
1896
|
+
if not patch_ids:
|
|
1897
|
+
# Empty stage file, no patches to merge
|
|
1898
|
+
return []
|
|
1899
|
+
|
|
1900
|
+
merged_patches = []
|
|
1901
|
+
|
|
1902
|
+
for patch_id in patch_ids:
|
|
1903
|
+
# Construct archived branch name
|
|
1904
|
+
archived_branch = f"ho-release/{version}/{patch_id}"
|
|
1905
|
+
|
|
1906
|
+
# Check if archived branch exists
|
|
1907
|
+
if not self._repo.hgit.branch_exists(archived_branch):
|
|
1908
|
+
raise ReleaseManagerError(
|
|
1909
|
+
f"Archived branch not found: {archived_branch}. "
|
|
1910
|
+
f"Patch {patch_id} was not properly archived during add-to-release."
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
# Merge archived branch into ho-prod with no-ff
|
|
1914
|
+
try:
|
|
1915
|
+
self._repo.hgit.merge(
|
|
1916
|
+
archived_branch,
|
|
1917
|
+
no_ff=True,
|
|
1918
|
+
m=f"Integrate patch {patch_id}")
|
|
1919
|
+
|
|
1920
|
+
merged_patches.append(patch_id)
|
|
1921
|
+
|
|
1922
|
+
except GitCommandError as e:
|
|
1923
|
+
raise ReleaseManagerError(
|
|
1924
|
+
f"Merge conflict with patch {patch_id} from {archived_branch}. "
|
|
1925
|
+
f"Resolve conflicts manually and retry. Git error: {e}"
|
|
1926
|
+
)
|
|
1927
|
+
|
|
1928
|
+
return merged_patches
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _cleanup_patch_branches(self, version: str, stage_file: str) -> List[str]:
|
|
1932
|
+
"""
|
|
1933
|
+
Delete all patch branches listed in stage file.
|
|
1934
|
+
|
|
1935
|
+
Reads patch list from stage file and deletes both local and remote
|
|
1936
|
+
branches. This is automatic cleanup at promote_to to maintain
|
|
1937
|
+
clean repository state. Branches are ho-patch/* format.
|
|
1938
|
+
|
|
1939
|
+
Algorithm:
|
|
1940
|
+
1. Read patch list from stage file
|
|
1941
|
+
2. For each patch:
|
|
1942
|
+
- Check if ho-patch/{patch_id} exists locally
|
|
1943
|
+
- If exists: git branch -D ho-patch/{patch_id}
|
|
1944
|
+
- Check if exists on remote
|
|
1945
|
+
- If exists: git push origin --delete ho-patch/{patch_id}
|
|
1946
|
+
3. Return list of deleted branches
|
|
1947
|
+
|
|
1948
|
+
Args:
|
|
1949
|
+
version: Version string (e.g., "1.3.5")
|
|
1950
|
+
stage_file: Stage filename (e.g., "1.3.5-stage.txt")
|
|
1951
|
+
|
|
1952
|
+
Returns:
|
|
1953
|
+
List of deleted branch names (e.g., ["ho-patch/456-user-auth", ...])
|
|
1954
|
+
|
|
1955
|
+
Raises:
|
|
1956
|
+
ReleaseManagerError: If branch deletion fails (e.g., uncommitted changes)
|
|
1957
|
+
|
|
1958
|
+
Examples:
|
|
1959
|
+
# Successful cleanup
|
|
1960
|
+
releases/1.3.5-stage.txt contains: ["456-user-auth", "789-security"]
|
|
1961
|
+
ho-patch/456-user-auth exists locally and remotely
|
|
1962
|
+
ho-patch/789-security exists locally and remotely
|
|
1963
|
+
|
|
1964
|
+
deleted = mgr._cleanup_patch_branches("1.3.5", "1.3.5-stage.txt")
|
|
1965
|
+
# → ["ho-patch/456-user-auth", "ho-patch/789-security"]
|
|
1966
|
+
# → Both branches deleted locally and remotely
|
|
1967
|
+
|
|
1968
|
+
# Branch already deleted
|
|
1969
|
+
releases/1.3.5-stage.txt contains: ["456-user-auth"]
|
|
1970
|
+
ho-patch/456-user-auth does NOT exist
|
|
1971
|
+
|
|
1972
|
+
deleted = mgr._cleanup_patch_branches("1.3.5", "1.3.5-stage.txt")
|
|
1973
|
+
# → [] (nothing to delete, no error)
|
|
1974
|
+
|
|
1975
|
+
# Branch with uncommitted changes (should not happen)
|
|
1976
|
+
ho-patch/456-user-auth has uncommitted changes
|
|
1977
|
+
|
|
1978
|
+
deleted = mgr._cleanup_patch_branches("1.3.5", "1.3.5-stage.txt")
|
|
1979
|
+
# → Raises: "Cannot delete ho-patch/456-user-auth: uncommitted changes"
|
|
1980
|
+
|
|
1981
|
+
Note:
|
|
1982
|
+
This is called AFTER merging archived branches to ho-prod, so the
|
|
1983
|
+
code is preserved in ho-prod even though branches are deleted.
|
|
1984
|
+
"""
|
|
1985
|
+
patch_ids = self.read_release_patches(stage_file)
|
|
1986
|
+
|
|
1987
|
+
if not patch_ids:
|
|
1988
|
+
# Empty stage file, no branches to cleanup
|
|
1989
|
+
return []
|
|
1990
|
+
|
|
1991
|
+
deleted_branches = []
|
|
1992
|
+
|
|
1993
|
+
for patch_id in patch_ids:
|
|
1994
|
+
# Construct branch name
|
|
1995
|
+
branch_name = f"ho-patch/{patch_id}"
|
|
1996
|
+
|
|
1997
|
+
# Delete local branch (force delete with -D)
|
|
1998
|
+
try:
|
|
1999
|
+
self._repo.hgit.delete_branch(branch_name, force=True)
|
|
2000
|
+
except GitCommandError as e:
|
|
2001
|
+
# Best effort: continue even if deletion fails
|
|
2002
|
+
# (branch might already be deleted)
|
|
2003
|
+
pass
|
|
2004
|
+
|
|
2005
|
+
# Delete remote branch
|
|
2006
|
+
try:
|
|
2007
|
+
self._repo.hgit.delete_remote_branch(branch_name)
|
|
2008
|
+
except GitCommandError as e:
|
|
2009
|
+
# Best effort: continue even if deletion fails
|
|
2010
|
+
# (branch might already be deleted from remote)
|
|
2011
|
+
pass
|
|
2012
|
+
|
|
2013
|
+
# Add to deleted list (best effort reporting)
|
|
2014
|
+
deleted_branches.append(branch_name)
|
|
2015
|
+
|
|
2016
|
+
return deleted_branches
|
|
2017
|
+
|
|
2018
|
+
def _ensure_patch_branch_synced(self, patch_id: str) -> dict:
|
|
2019
|
+
"""
|
|
2020
|
+
Ensure patch branch is synced with ho-prod before integration.
|
|
2021
|
+
|
|
2022
|
+
Automatically syncs patch branch by merging ho-prod INTO the patch branch.
|
|
2023
|
+
This ensures the patch branch has all latest changes from ho-prod before
|
|
2024
|
+
being integrated back into the release.
|
|
2025
|
+
|
|
2026
|
+
Direction: ho-prod → ho-patch/{patch_id}
|
|
2027
|
+
(update patch branch with latest production changes)
|
|
2028
|
+
|
|
2029
|
+
Simple merge strategy: ho-prod is merged INTO the patch branch using
|
|
2030
|
+
standard git merge. No fast-forward or rebase needed since full commit
|
|
2031
|
+
history is preserved during promote_to (no squash).
|
|
2032
|
+
|
|
2033
|
+
Sync Strategy:
|
|
2034
|
+
1. Check if already synced → return immediately
|
|
2035
|
+
2. Merge ho-prod into patch branch (standard merge)
|
|
2036
|
+
3. If merge conflicts, block for manual resolution
|
|
2037
|
+
|
|
2038
|
+
This simple approach is appropriate because:
|
|
2039
|
+
- Full history is preserved at promote_to (no squash)
|
|
2040
|
+
- Merge commits in patch branches are acceptable
|
|
2041
|
+
- Individual commit history matters for traceability
|
|
2042
|
+
|
|
2043
|
+
Args:
|
|
2044
|
+
patch_id: Patch identifier (e.g., "456-user-auth")
|
|
2045
|
+
|
|
2046
|
+
Returns:
|
|
2047
|
+
dict: Sync result with keys:
|
|
2048
|
+
- 'strategy': Strategy used for sync
|
|
2049
|
+
* "already-synced": No action needed
|
|
2050
|
+
* "fast-forward": Clean fast-forward merge
|
|
2051
|
+
* "rebase": Linear history via rebase
|
|
2052
|
+
* "merge": Safe merge with merge commit
|
|
2053
|
+
- 'branch_name': Full branch name (e.g., "ho-patch/456-user-auth")
|
|
2054
|
+
|
|
2055
|
+
Raises:
|
|
2056
|
+
ReleaseManagerError: If automatic sync fails due to conflicts
|
|
2057
|
+
requiring manual resolution. Error message includes specific
|
|
2058
|
+
instructions for manual conflict resolution.
|
|
2059
|
+
|
|
2060
|
+
Examples:
|
|
2061
|
+
# Already synced
|
|
2062
|
+
result = self._ensure_patch_branch_synced("456-user-auth")
|
|
2063
|
+
# Returns: {'strategy': 'already-synced', 'branch_name': 'ho-patch/456-user-auth'}
|
|
2064
|
+
|
|
2065
|
+
# Behind - fast-forward successful
|
|
2066
|
+
result = self._ensure_patch_branch_synced("789-security")
|
|
2067
|
+
# Returns: {'strategy': 'fast-forward', 'branch_name': 'ho-patch/789-security'}
|
|
2068
|
+
|
|
2069
|
+
# Diverged - rebase successful
|
|
2070
|
+
result = self._ensure_patch_branch_synced("234-reports")
|
|
2071
|
+
# Returns: {'strategy': 'rebase', 'branch_name': 'ho-patch/234-reports'}
|
|
2072
|
+
|
|
2073
|
+
# Conflicts require manual resolution
|
|
2074
|
+
try:
|
|
2075
|
+
result = self._ensure_patch_branch_synced("999-bugfix")
|
|
2076
|
+
except ReleaseManagerError as e:
|
|
2077
|
+
# Error with manual resolution instructions
|
|
2078
|
+
pass
|
|
2079
|
+
|
|
2080
|
+
Side Effects:
|
|
2081
|
+
- Checks out patch branch temporarily
|
|
2082
|
+
- May create commits (merge) or rewrite history (rebase)
|
|
2083
|
+
- Pushes changes to remote (may require force push for rebase)
|
|
2084
|
+
- Returns to original branch after sync
|
|
2085
|
+
|
|
2086
|
+
Notes:
|
|
2087
|
+
- Fast-forward is preferred (cleanest, no extra commits)
|
|
2088
|
+
- Rebase is acceptable for ephemeral ho-patch/* branches
|
|
2089
|
+
- Merge is fallback when rebase has conflicts
|
|
2090
|
+
- Manual resolution required only for unresolvable conflicts
|
|
2091
|
+
- Non-blocking: continues workflow after successful sync
|
|
2092
|
+
"""
|
|
2093
|
+
branch_name = f"ho-patch/{patch_id}"
|
|
2094
|
+
|
|
2095
|
+
# 1. Check if already synced
|
|
2096
|
+
is_synced, status = self._repo.hgit.is_branch_synced(branch_name)
|
|
2097
|
+
|
|
2098
|
+
if is_synced:
|
|
2099
|
+
return {
|
|
2100
|
+
'strategy': 'already-synced',
|
|
2101
|
+
'branch_name': branch_name
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
# 2. Save current branch to return to later
|
|
2105
|
+
current_branch = self._repo.hgit.branch
|
|
2106
|
+
|
|
2107
|
+
try:
|
|
2108
|
+
# 3. Checkout patch branch
|
|
2109
|
+
self._repo.hgit.checkout(branch_name)
|
|
2110
|
+
|
|
2111
|
+
# 4. Merge ho-prod into patch branch (standard merge)
|
|
2112
|
+
try:
|
|
2113
|
+
self._repo.hgit.merge("ho-prod")
|
|
2114
|
+
|
|
2115
|
+
# 5. Push changes to remote
|
|
2116
|
+
self._repo.hgit.push()
|
|
2117
|
+
|
|
2118
|
+
# Success - return merge strategy
|
|
2119
|
+
return {
|
|
2120
|
+
'strategy': 'merge',
|
|
2121
|
+
'branch_name': branch_name
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
except GitCommandError as e:
|
|
2125
|
+
# Merge conflicts - manual resolution required
|
|
2126
|
+
raise ReleaseManagerError(
|
|
2127
|
+
f"Branch {branch_name} has conflicts with ho-prod.\n"
|
|
2128
|
+
f"Manual resolution required:\n\n"
|
|
2129
|
+
f" git checkout {branch_name}\n"
|
|
2130
|
+
f" git merge ho-prod\n"
|
|
2131
|
+
f" # Resolve conflicts in your editor\n"
|
|
2132
|
+
f" git add .\n"
|
|
2133
|
+
f" git commit\n"
|
|
2134
|
+
f" git push\n\n"
|
|
2135
|
+
f"Then retry: half_orm dev add-to-release {patch_id}\n\n"
|
|
2136
|
+
f"Git error: {e}"
|
|
2137
|
+
)
|
|
2138
|
+
|
|
2139
|
+
finally:
|
|
2140
|
+
# 6. Always return to original branch (best effort)
|
|
2141
|
+
try:
|
|
2142
|
+
self._repo.hgit.checkout(current_branch)
|
|
2143
|
+
except Exception:
|
|
2144
|
+
# Best effort - don't fail if checkout back fails
|
|
2145
|
+
pass
|
|
2146
|
+
|
|
2147
|
+
def update_production(self) -> dict:
|
|
2148
|
+
"""
|
|
2149
|
+
Fetch tags and list available releases for production upgrade (read-only).
|
|
2150
|
+
|
|
2151
|
+
Equivalent to 'apt update' - synchronizes with origin and shows available
|
|
2152
|
+
releases but makes NO modifications to database or repository.
|
|
2153
|
+
|
|
2154
|
+
Workflow:
|
|
2155
|
+
1. Fetch tags from origin (git fetch --tags)
|
|
2156
|
+
2. Read current production version from database (hop_last_release)
|
|
2157
|
+
3. List available release tags (v1.3.6, v1.3.6-rc1, v1.4.0)
|
|
2158
|
+
4. Calculate sequential upgrade path
|
|
2159
|
+
5. Return structured results for CLI display
|
|
2160
|
+
|
|
2161
|
+
Returns:
|
|
2162
|
+
dict: Update information with structure:
|
|
2163
|
+
{
|
|
2164
|
+
'current_version': str, # e.g., "1.3.5"
|
|
2165
|
+
'available_releases': List[dict], # List of available tags
|
|
2166
|
+
'upgrade_path': List[str], # Sequential path
|
|
2167
|
+
'has_updates': bool # True if updates available
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
Each item in 'available_releases':
|
|
2171
|
+
{
|
|
2172
|
+
'tag': str, # e.g., "v1.3.6"
|
|
2173
|
+
'version': str, # e.g., "1.3.6"
|
|
2174
|
+
'type': str, # 'production', 'rc', or 'hotfix'
|
|
2175
|
+
'patches': List[str] # Patch IDs in release
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
Raises:
|
|
2179
|
+
ReleaseManagerError: If cannot fetch tags or read database version
|
|
2180
|
+
|
|
2181
|
+
Examples:
|
|
2182
|
+
# List available production releases
|
|
2183
|
+
result = mgr.update_production()
|
|
2184
|
+
print(f"Current: {result['current_version']}")
|
|
2185
|
+
for rel in result['available_releases']:
|
|
2186
|
+
print(f" → {rel['version']} ({len(rel['patches'])} patches)")
|
|
2187
|
+
|
|
2188
|
+
# Include RC releases
|
|
2189
|
+
result = mgr.update_production()
|
|
2190
|
+
# → Shows v1.3.6-rc1, v1.3.6, v1.4.0
|
|
2191
|
+
"""
|
|
2192
|
+
allow_rc = self._repo.allow_rc
|
|
2193
|
+
|
|
2194
|
+
# 1. Get available release tags from origin
|
|
2195
|
+
available_tags = self._get_available_release_tags(allow_rc=allow_rc)
|
|
2196
|
+
|
|
2197
|
+
# 2. Read current production version from database
|
|
2198
|
+
try:
|
|
2199
|
+
current_version = self._repo.database.last_release_s
|
|
2200
|
+
except Exception as e:
|
|
2201
|
+
raise ReleaseManagerError(
|
|
2202
|
+
f"Cannot read current production version from database: {e}"
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
# 3. Build list of available releases with details
|
|
2206
|
+
available_releases = []
|
|
2207
|
+
|
|
2208
|
+
for tag in available_tags:
|
|
2209
|
+
# Extract version from tag (remove 'v' prefix)
|
|
2210
|
+
version = tag[1:]
|
|
2211
|
+
|
|
2212
|
+
# Determine release type
|
|
2213
|
+
if '-rc' in version:
|
|
2214
|
+
release_type = 'rc'
|
|
2215
|
+
elif '-hotfix' in version:
|
|
2216
|
+
release_type = 'hotfix'
|
|
2217
|
+
else:
|
|
2218
|
+
release_type = 'production'
|
|
2219
|
+
|
|
2220
|
+
# Extract base version for file lookup (remove suffix)
|
|
2221
|
+
base_version = version.split('-')[0]
|
|
2222
|
+
|
|
2223
|
+
# Read patches from release file
|
|
2224
|
+
release_file = self._releases_dir / f"{version}.txt"
|
|
2225
|
+
patches = []
|
|
2226
|
+
|
|
2227
|
+
if release_file.exists():
|
|
2228
|
+
content = release_file.read_text().strip()
|
|
2229
|
+
if content:
|
|
2230
|
+
patches = [line.strip() for line in content.split('\n') if line.strip()]
|
|
2231
|
+
|
|
2232
|
+
# Only include releases newer than current version
|
|
2233
|
+
if self._version_is_newer(version, current_version):
|
|
2234
|
+
available_releases.append({
|
|
2235
|
+
'tag': tag,
|
|
2236
|
+
'version': version,
|
|
2237
|
+
'type': release_type,
|
|
2238
|
+
'patches': patches
|
|
2239
|
+
})
|
|
2240
|
+
|
|
2241
|
+
# 4. Calculate upgrade path (implemented in Artefact 3B)
|
|
2242
|
+
upgrade_path = []
|
|
2243
|
+
if available_releases:
|
|
2244
|
+
# Extract production versions only for upgrade path
|
|
2245
|
+
production_versions = [
|
|
2246
|
+
rel['version'] for rel in available_releases
|
|
2247
|
+
if rel['type'] == 'production'
|
|
2248
|
+
]
|
|
2249
|
+
|
|
2250
|
+
if production_versions:
|
|
2251
|
+
# Use last production version as target
|
|
2252
|
+
target_version = production_versions[-1]
|
|
2253
|
+
upgrade_path = self._calculate_upgrade_path(current_version, target_version)
|
|
2254
|
+
|
|
2255
|
+
# 5. Return results
|
|
2256
|
+
return {
|
|
2257
|
+
'current_version': current_version,
|
|
2258
|
+
'available_releases': available_releases,
|
|
2259
|
+
'upgrade_path': upgrade_path,
|
|
2260
|
+
'has_updates': len(available_releases) > 0
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
def _get_available_release_tags(self, allow_rc: bool = False) -> List[str]:
|
|
2264
|
+
"""
|
|
2265
|
+
Get available release tags from Git repository.
|
|
2266
|
+
|
|
2267
|
+
Fetches tags from origin and filters for release tags (v*.*.*).
|
|
2268
|
+
Excludes RC tags unless allow_rc=True.
|
|
2269
|
+
|
|
2270
|
+
Args:
|
|
2271
|
+
allow_rc: If True, include RC tags (v1.3.6-rc1)
|
|
2272
|
+
|
|
2273
|
+
Returns:
|
|
2274
|
+
List[str]: Sorted list of tag names (e.g., ["v1.3.6", "v1.4.0"])
|
|
2275
|
+
|
|
2276
|
+
Raises:
|
|
2277
|
+
ReleaseManagerError: If fetch fails
|
|
2278
|
+
|
|
2279
|
+
Examples:
|
|
2280
|
+
# Production only
|
|
2281
|
+
tags = mgr._get_available_release_tags()
|
|
2282
|
+
# → ["v1.3.6", "v1.4.0"]
|
|
2283
|
+
|
|
2284
|
+
# Include RC
|
|
2285
|
+
tags = mgr._get_available_release_tags(allow_rc=True)
|
|
2286
|
+
# → ["v1.3.6-rc1", "v1.3.6", "v1.4.0"]
|
|
2287
|
+
"""
|
|
2288
|
+
try:
|
|
2289
|
+
# Fetch tags from origin
|
|
2290
|
+
self._repo.hgit.fetch_tags()
|
|
2291
|
+
except Exception as e:
|
|
2292
|
+
raise ReleaseManagerError(f"Failed to fetch tags from origin: {e}")
|
|
2293
|
+
|
|
2294
|
+
# Get all tags from repository
|
|
2295
|
+
try:
|
|
2296
|
+
all_tags = self._repo.hgit._HGit__git_repo.tags
|
|
2297
|
+
except Exception as e:
|
|
2298
|
+
raise ReleaseManagerError(f"Failed to read tags from repository: {e}")
|
|
2299
|
+
|
|
2300
|
+
# Filter for release tags (v*.*.*) with optional -rc or -hotfix suffix
|
|
2301
|
+
release_pattern = re.compile(r'^v\d+\.\d+\.\d+(-rc\d+|-hotfix\d+)?$')
|
|
2302
|
+
release_tags = []
|
|
2303
|
+
|
|
2304
|
+
for tag in all_tags:
|
|
2305
|
+
tag_name = tag.name
|
|
2306
|
+
if release_pattern.match(tag_name):
|
|
2307
|
+
# Filter RC tags unless explicitly allowed
|
|
2308
|
+
if '-rc' in tag_name and not allow_rc:
|
|
2309
|
+
continue
|
|
2310
|
+
release_tags.append(tag_name)
|
|
2311
|
+
|
|
2312
|
+
# Sort tags by version (semantic versioning)
|
|
2313
|
+
def version_key(tag_name):
|
|
2314
|
+
"""Extract sortable version tuple from tag name."""
|
|
2315
|
+
# Remove 'v' prefix
|
|
2316
|
+
version_str = tag_name[1:]
|
|
2317
|
+
|
|
2318
|
+
# Split version and suffix
|
|
2319
|
+
if '-rc' in version_str:
|
|
2320
|
+
base_ver, rc_suffix = version_str.split('-rc')
|
|
2321
|
+
rc_num = int(rc_suffix)
|
|
2322
|
+
suffix_weight = (1, rc_num) # RC comes before production
|
|
2323
|
+
elif '-hotfix' in version_str:
|
|
2324
|
+
base_ver, hotfix_suffix = version_str.split('-hotfix')
|
|
2325
|
+
hotfix_num = int(hotfix_suffix)
|
|
2326
|
+
suffix_weight = (2, hotfix_num) # Hotfix comes after production
|
|
2327
|
+
else:
|
|
2328
|
+
base_ver = version_str
|
|
2329
|
+
suffix_weight = (1.5, 0) # Production between RC and hotfix
|
|
2330
|
+
|
|
2331
|
+
# Parse base version
|
|
2332
|
+
major, minor, patch = map(int, base_ver.split('.'))
|
|
2333
|
+
|
|
2334
|
+
return (major, minor, patch, suffix_weight)
|
|
2335
|
+
|
|
2336
|
+
release_tags.sort(key=version_key)
|
|
2337
|
+
|
|
2338
|
+
return release_tags
|
|
2339
|
+
|
|
2340
|
+
def _calculate_upgrade_path(
|
|
2341
|
+
self,
|
|
2342
|
+
current: str,
|
|
2343
|
+
target: str
|
|
2344
|
+
) -> List[str]:
|
|
2345
|
+
"""
|
|
2346
|
+
Calculate sequential upgrade path between two versions.
|
|
2347
|
+
|
|
2348
|
+
Determines all intermediate versions needed to upgrade from
|
|
2349
|
+
current to target version. Versions must be applied sequentially.
|
|
2350
|
+
|
|
2351
|
+
Args:
|
|
2352
|
+
current: Current production version (e.g., "1.3.5")
|
|
2353
|
+
target: Target version (e.g., "1.4.0")
|
|
2354
|
+
|
|
2355
|
+
Returns:
|
|
2356
|
+
List[str]: Ordered list of versions to apply
|
|
2357
|
+
|
|
2358
|
+
Examples:
|
|
2359
|
+
# Direct upgrade
|
|
2360
|
+
path = mgr._calculate_upgrade_path("1.3.5", "1.3.6")
|
|
2361
|
+
# → ["1.3.6"]
|
|
2362
|
+
|
|
2363
|
+
# Multi-step upgrade
|
|
2364
|
+
path = mgr._calculate_upgrade_path("1.3.5", "1.4.0")
|
|
2365
|
+
# → ["1.3.6", "1.4.0"]
|
|
2366
|
+
|
|
2367
|
+
# No upgrades needed
|
|
2368
|
+
path = mgr._calculate_upgrade_path("1.4.0", "1.4.0")
|
|
2369
|
+
# → []
|
|
2370
|
+
"""
|
|
2371
|
+
# Parse versions
|
|
2372
|
+
current_version = self.parse_version_from_filename(f"{current}.txt")
|
|
2373
|
+
target_version = self.parse_version_from_filename(f"{target}.txt")
|
|
2374
|
+
|
|
2375
|
+
# If same version, no upgrade needed
|
|
2376
|
+
if current == target:
|
|
2377
|
+
return []
|
|
2378
|
+
|
|
2379
|
+
# Get all available release tags (production only)
|
|
2380
|
+
available_tags = self._get_available_release_tags(allow_rc=False)
|
|
2381
|
+
|
|
2382
|
+
# Extract versions from tags and parse them
|
|
2383
|
+
available_versions = []
|
|
2384
|
+
for tag in available_tags:
|
|
2385
|
+
# Remove 'v' prefix: v1.3.6 → 1.3.6
|
|
2386
|
+
version_str = tag[1:] if tag.startswith('v') else tag
|
|
2387
|
+
|
|
2388
|
+
# Skip if not a valid production version format
|
|
2389
|
+
if not re.match(r'^\d+\.\d+\.\d+$', version_str):
|
|
2390
|
+
continue
|
|
2391
|
+
|
|
2392
|
+
try:
|
|
2393
|
+
version = self.parse_version_from_filename(f"{version_str}.txt")
|
|
2394
|
+
available_versions.append((version_str, version))
|
|
2395
|
+
except Exception:
|
|
2396
|
+
continue
|
|
2397
|
+
|
|
2398
|
+
# Sort versions
|
|
2399
|
+
available_versions.sort(key=lambda x: (x[1].major, x[1].minor, x[1].patch))
|
|
2400
|
+
|
|
2401
|
+
# Build sequential path from current to target
|
|
2402
|
+
path = []
|
|
2403
|
+
for version_str, version in available_versions:
|
|
2404
|
+
# Skip versions <= current
|
|
2405
|
+
if (version.major, version.minor, version.patch) <= \
|
|
2406
|
+
(current_version.major, current_version.minor, current_version.patch):
|
|
2407
|
+
continue
|
|
2408
|
+
|
|
2409
|
+
# Add versions <= target
|
|
2410
|
+
if (version.major, version.minor, version.patch) <= \
|
|
2411
|
+
(target_version.major, target_version.minor, target_version.patch):
|
|
2412
|
+
path.append(version_str)
|
|
2413
|
+
|
|
2414
|
+
return path
|
|
2415
|
+
|
|
2416
|
+
def _version_is_newer(self, version1: str, version2: str) -> bool:
|
|
2417
|
+
"""
|
|
2418
|
+
Compare two version strings to check if version1 is newer than version2.
|
|
2419
|
+
|
|
2420
|
+
Args:
|
|
2421
|
+
version1: First version (e.g., "1.3.6", "1.3.6-rc1")
|
|
2422
|
+
version2: Second version (e.g., "1.3.5")
|
|
2423
|
+
|
|
2424
|
+
Returns:
|
|
2425
|
+
bool: True if version1 > version2
|
|
2426
|
+
|
|
2427
|
+
Examples:
|
|
2428
|
+
_version_is_newer("1.3.6", "1.3.5") # → True
|
|
2429
|
+
_version_is_newer("1.3.5", "1.3.6") # → False
|
|
2430
|
+
_version_is_newer("1.3.6-rc1", "1.3.5") # → True
|
|
2431
|
+
"""
|
|
2432
|
+
# Extract base versions (remove suffix)
|
|
2433
|
+
base1 = version1.split('-')[0]
|
|
2434
|
+
base2 = version2.split('-')[0]
|
|
2435
|
+
|
|
2436
|
+
# Parse versions
|
|
2437
|
+
parts1 = tuple(map(int, base1.split('.')))
|
|
2438
|
+
parts2 = tuple(map(int, base2.split('.')))
|
|
2439
|
+
|
|
2440
|
+
return parts1 > parts2
|
|
2441
|
+
|
|
2442
|
+
def upgrade_production(
|
|
2443
|
+
self,
|
|
2444
|
+
to_version: Optional[str] = None,
|
|
2445
|
+
dry_run: bool = False,
|
|
2446
|
+
force_backup: bool = False,
|
|
2447
|
+
skip_backup: bool = False
|
|
2448
|
+
) -> dict:
|
|
2449
|
+
"""
|
|
2450
|
+
Upgrade production database to target version.
|
|
2451
|
+
|
|
2452
|
+
Applies releases sequentially to production database. This is the
|
|
2453
|
+
production-safe upgrade workflow that NEVER destroys the database,
|
|
2454
|
+
working incrementally on existing data.
|
|
2455
|
+
|
|
2456
|
+
CRITICAL: This method works on EXISTING production database.
|
|
2457
|
+
It does NOT use restore_database_from_schema() which would destroy data.
|
|
2458
|
+
|
|
2459
|
+
Workflow:
|
|
2460
|
+
1. CREATE BACKUP (first action, before any validation)
|
|
2461
|
+
2. Validate production environment (ho-prod branch, clean repo)
|
|
2462
|
+
3. Fetch available releases via update_production()
|
|
2463
|
+
4. Calculate upgrade path (all or to specific version)
|
|
2464
|
+
5. Apply each release sequentially on existing database
|
|
2465
|
+
6. Update database version after each release
|
|
2466
|
+
|
|
2467
|
+
Args:
|
|
2468
|
+
to_version: Stop at specific version (e.g., "1.3.6")
|
|
2469
|
+
If None, apply all available releases
|
|
2470
|
+
dry_run: Simulate without modifying database or creating backup
|
|
2471
|
+
force_backup: Overwrite existing backup file without confirmation
|
|
2472
|
+
skip_backup: Skip backup creation (DANGEROUS - for testing only)
|
|
2473
|
+
|
|
2474
|
+
Returns:
|
|
2475
|
+
dict: Upgrade result with detailed information
|
|
2476
|
+
|
|
2477
|
+
Structure:
|
|
2478
|
+
'status': 'success' or 'dry_run'
|
|
2479
|
+
'dry_run': bool
|
|
2480
|
+
'backup_created': Path or None (if dry_run or skip_backup)
|
|
2481
|
+
'current_version': str (version before upgrade)
|
|
2482
|
+
'target_version': str or None (explicit target or None for "all")
|
|
2483
|
+
'releases_applied': List[str] (versions applied)
|
|
2484
|
+
'patches_applied': Dict[str, List[str]] (patches per release)
|
|
2485
|
+
'final_version': str (version after upgrade)
|
|
2486
|
+
|
|
2487
|
+
Raises:
|
|
2488
|
+
ReleaseManagerError: For validation failures or application errors
|
|
2489
|
+
|
|
2490
|
+
Examples:
|
|
2491
|
+
# Upgrade to latest (all available releases)
|
|
2492
|
+
result = mgr.upgrade_production()
|
|
2493
|
+
# Current: 1.3.5
|
|
2494
|
+
# Applies: 1.3.6 → 1.3.7 → 1.4.0
|
|
2495
|
+
# Result: {
|
|
2496
|
+
# 'status': 'success',
|
|
2497
|
+
# 'backup_created': Path('backups/1.3.5.sql'),
|
|
2498
|
+
# 'current_version': '1.3.5',
|
|
2499
|
+
# 'target_version': None,
|
|
2500
|
+
# 'releases_applied': ['1.3.6', '1.3.7', '1.4.0'],
|
|
2501
|
+
# 'patches_applied': {
|
|
2502
|
+
# '1.3.6': ['456-auth', '789-security'],
|
|
2503
|
+
# '1.3.7': ['999-bugfix'],
|
|
2504
|
+
# '1.4.0': ['111-feature']
|
|
2505
|
+
# },
|
|
2506
|
+
# 'final_version': '1.4.0'
|
|
2507
|
+
# }
|
|
2508
|
+
|
|
2509
|
+
# Upgrade to specific version
|
|
2510
|
+
result = mgr.upgrade_production(to_version="1.3.7")
|
|
2511
|
+
# Current: 1.3.5
|
|
2512
|
+
# Applies: 1.3.6 → 1.3.7 (stops here)
|
|
2513
|
+
# Result: {
|
|
2514
|
+
# 'status': 'success',
|
|
2515
|
+
# 'target_version': '1.3.7',
|
|
2516
|
+
# 'releases_applied': ['1.3.6', '1.3.7'],
|
|
2517
|
+
# 'final_version': '1.3.7'
|
|
2518
|
+
# }
|
|
2519
|
+
|
|
2520
|
+
# Dry run (no changes)
|
|
2521
|
+
result = mgr.upgrade_production(dry_run=True)
|
|
2522
|
+
# Result: {
|
|
2523
|
+
# 'status': 'dry_run',
|
|
2524
|
+
# 'dry_run': True,
|
|
2525
|
+
# 'backup_would_be_created': 'backups/1.3.5.sql',
|
|
2526
|
+
# 'releases_would_apply': ['1.3.6', '1.3.7'],
|
|
2527
|
+
# 'patches_would_apply': {...}
|
|
2528
|
+
# }
|
|
2529
|
+
|
|
2530
|
+
# Already up to date
|
|
2531
|
+
result = mgr.upgrade_production()
|
|
2532
|
+
# Result: {
|
|
2533
|
+
# 'status': 'success',
|
|
2534
|
+
# 'current_version': '1.4.0',
|
|
2535
|
+
# 'releases_applied': [],
|
|
2536
|
+
# 'message': 'Production already at latest version'
|
|
2537
|
+
# }
|
|
2538
|
+
"""
|
|
2539
|
+
from half_orm_dev.release_manager import ReleaseManagerError
|
|
2540
|
+
|
|
2541
|
+
# Get current version
|
|
2542
|
+
current_version = self._repo.database.last_release_s
|
|
2543
|
+
|
|
2544
|
+
# === 1. BACKUP FIRST (unless dry_run or skip_backup) ===
|
|
2545
|
+
backup_path = None
|
|
2546
|
+
if not dry_run and not skip_backup:
|
|
2547
|
+
backup_path = self._create_production_backup(
|
|
2548
|
+
current_version,
|
|
2549
|
+
force=force_backup
|
|
2550
|
+
)
|
|
2551
|
+
|
|
2552
|
+
# === 2. Validate environment ===
|
|
2553
|
+
self._validate_production_upgrade()
|
|
2554
|
+
|
|
2555
|
+
# === 3. Get available releases ===
|
|
2556
|
+
update_info = self.update_production()
|
|
2557
|
+
|
|
2558
|
+
# Check if already up to date
|
|
2559
|
+
if not update_info['has_updates']:
|
|
2560
|
+
return {
|
|
2561
|
+
'status': 'success',
|
|
2562
|
+
'dry_run': False,
|
|
2563
|
+
'backup_created': backup_path,
|
|
2564
|
+
'current_version': current_version,
|
|
2565
|
+
'target_version': to_version,
|
|
2566
|
+
'releases_applied': [],
|
|
2567
|
+
'patches_applied': {},
|
|
2568
|
+
'final_version': current_version,
|
|
2569
|
+
'message': 'Production already at latest version'
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
# === 4. Calculate upgrade path ===
|
|
2573
|
+
if to_version:
|
|
2574
|
+
# Upgrade to specific version
|
|
2575
|
+
full_path = update_info['upgrade_path']
|
|
2576
|
+
|
|
2577
|
+
# Validate target version exists
|
|
2578
|
+
if to_version not in full_path:
|
|
2579
|
+
raise ReleaseManagerError(
|
|
2580
|
+
f"Target version {to_version} not in upgrade path. "
|
|
2581
|
+
f"Available versions: {', '.join(full_path)}"
|
|
2582
|
+
)
|
|
2583
|
+
|
|
2584
|
+
# Truncate path to target
|
|
2585
|
+
upgrade_path = []
|
|
2586
|
+
for version in full_path:
|
|
2587
|
+
upgrade_path.append(version)
|
|
2588
|
+
if version == to_version:
|
|
2589
|
+
break
|
|
2590
|
+
else:
|
|
2591
|
+
# Upgrade to latest (all releases)
|
|
2592
|
+
upgrade_path = update_info['upgrade_path']
|
|
2593
|
+
|
|
2594
|
+
# === DRY RUN - Stop here and return simulation ===
|
|
2595
|
+
if dry_run:
|
|
2596
|
+
# Build patches_would_apply dict
|
|
2597
|
+
patches_would_apply = {}
|
|
2598
|
+
for version in upgrade_path:
|
|
2599
|
+
patches = self.read_release_patches(f"{version}.txt")
|
|
2600
|
+
patches_would_apply[version] = patches
|
|
2601
|
+
|
|
2602
|
+
return {
|
|
2603
|
+
'status': 'dry_run',
|
|
2604
|
+
'dry_run': True,
|
|
2605
|
+
'backup_would_be_created': f'backups/{current_version}.sql',
|
|
2606
|
+
'current_version': current_version,
|
|
2607
|
+
'target_version': to_version,
|
|
2608
|
+
'releases_would_apply': upgrade_path,
|
|
2609
|
+
'patches_would_apply': patches_would_apply,
|
|
2610
|
+
'final_version': upgrade_path[-1] if upgrade_path else current_version
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
# === 5. Apply releases sequentially ===
|
|
2614
|
+
patches_applied = {}
|
|
2615
|
+
|
|
2616
|
+
try:
|
|
2617
|
+
for version in upgrade_path:
|
|
2618
|
+
# Apply release and collect patches
|
|
2619
|
+
applied_patches = self._apply_release_to_production(version)
|
|
2620
|
+
patches_applied[version] = applied_patches
|
|
2621
|
+
|
|
2622
|
+
except Exception as e:
|
|
2623
|
+
# On error, provide rollback instructions
|
|
2624
|
+
raise ReleaseManagerError(
|
|
2625
|
+
f"Failed to apply release {version}: {e}\n\n"
|
|
2626
|
+
f"ROLLBACK INSTRUCTIONS:\n"
|
|
2627
|
+
f"1. Restore database: psql -d {self._repo.database.name} -f {backup_path}\n"
|
|
2628
|
+
f"2. Verify restoration: SELECT * FROM half_orm_meta.hop_release ORDER BY id DESC LIMIT 1;\n"
|
|
2629
|
+
f"3. Fix the failing patch and retry upgrade"
|
|
2630
|
+
) from e
|
|
2631
|
+
|
|
2632
|
+
# === 6. Build success result ===
|
|
2633
|
+
final_version = upgrade_path[-1] if upgrade_path else current_version
|
|
2634
|
+
|
|
2635
|
+
return {
|
|
2636
|
+
'status': 'success',
|
|
2637
|
+
'dry_run': False,
|
|
2638
|
+
'backup_created': backup_path,
|
|
2639
|
+
'current_version': current_version,
|
|
2640
|
+
'target_version': to_version,
|
|
2641
|
+
'releases_applied': upgrade_path,
|
|
2642
|
+
'patches_applied': patches_applied,
|
|
2643
|
+
'final_version': final_version
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
def _create_production_backup(
|
|
2648
|
+
self,
|
|
2649
|
+
current_version: str,
|
|
2650
|
+
force: bool = False
|
|
2651
|
+
) -> Path:
|
|
2652
|
+
"""
|
|
2653
|
+
Create production database backup before upgrade.
|
|
2654
|
+
|
|
2655
|
+
Creates backups/{version}.sql using pg_dump with full database dump
|
|
2656
|
+
(schema + data + metadata). This is the rollback point if upgrade fails.
|
|
2657
|
+
|
|
2658
|
+
Args:
|
|
2659
|
+
current_version: Current database version (e.g., "1.3.5")
|
|
2660
|
+
force: Overwrite existing backup without confirmation
|
|
2661
|
+
|
|
2662
|
+
Returns:
|
|
2663
|
+
Path: Backup file path (e.g., Path("backups/1.3.5.sql"))
|
|
2664
|
+
|
|
2665
|
+
Raises:
|
|
2666
|
+
ReleaseManagerError: If backup creation fails or user declines overwrite
|
|
2667
|
+
|
|
2668
|
+
Examples:
|
|
2669
|
+
# Create new backup
|
|
2670
|
+
path = mgr._create_production_backup("1.3.5")
|
|
2671
|
+
# → Creates backups/1.3.5.sql
|
|
2672
|
+
# → Returns Path('backups/1.3.5.sql')
|
|
2673
|
+
|
|
2674
|
+
# Backup exists, user confirms overwrite
|
|
2675
|
+
path = mgr._create_production_backup("1.3.5", force=False)
|
|
2676
|
+
# → Prompt: "Backup exists. Overwrite? [y/N]"
|
|
2677
|
+
# → User enters 'y'
|
|
2678
|
+
# → Overwrites backups/1.3.5.sql
|
|
2679
|
+
|
|
2680
|
+
# Backup exists, force=True
|
|
2681
|
+
path = mgr._create_production_backup("1.3.5", force=True)
|
|
2682
|
+
# → Overwrites without prompt
|
|
2683
|
+
|
|
2684
|
+
# Backup exists, user declines
|
|
2685
|
+
path = mgr._create_production_backup("1.3.5", force=False)
|
|
2686
|
+
# → User enters 'n'
|
|
2687
|
+
# → Raises: "Backup exists and user declined overwrite"
|
|
2688
|
+
"""
|
|
2689
|
+
from half_orm_dev.release_manager import ReleaseManagerError
|
|
2690
|
+
|
|
2691
|
+
# Create backups directory if doesn't exist
|
|
2692
|
+
backups_dir = Path(self._repo.base_dir) / "backups"
|
|
2693
|
+
backups_dir.mkdir(exist_ok=True)
|
|
2694
|
+
|
|
2695
|
+
# Build backup filename
|
|
2696
|
+
backup_file = backups_dir / f"{current_version}.sql"
|
|
2697
|
+
|
|
2698
|
+
# Check if backup already exists
|
|
2699
|
+
if backup_file.exists() and not force:
|
|
2700
|
+
# Prompt user for confirmation
|
|
2701
|
+
response = input(
|
|
2702
|
+
f"Backup {backup_file} already exists. "
|
|
2703
|
+
f"Overwrite? [y/N]: "
|
|
2704
|
+
).strip().lower()
|
|
2705
|
+
|
|
2706
|
+
if response != 'y':
|
|
2707
|
+
raise ReleaseManagerError(
|
|
2708
|
+
f"Backup {backup_file} already exists. "
|
|
2709
|
+
f"Use --force to overwrite or remove the file manually."
|
|
2710
|
+
)
|
|
2711
|
+
|
|
2712
|
+
# Create backup using pg_dump
|
|
2713
|
+
try:
|
|
2714
|
+
self._repo.database.execute_pg_command(
|
|
2715
|
+
'pg_dump',
|
|
2716
|
+
'-f', str(backup_file),
|
|
2717
|
+
)
|
|
2718
|
+
except Exception as e:
|
|
2719
|
+
raise ReleaseManagerError(
|
|
2720
|
+
f"Failed to create backup {backup_file}: {e}"
|
|
2721
|
+
) from e
|
|
2722
|
+
|
|
2723
|
+
return backup_file
|
|
2724
|
+
|
|
2725
|
+
|
|
2726
|
+
def _validate_production_upgrade(self) -> None:
|
|
2727
|
+
"""
|
|
2728
|
+
Validate production environment before upgrade.
|
|
2729
|
+
|
|
2730
|
+
Checks:
|
|
2731
|
+
1. Current branch is ho-prod (production branch)
|
|
2732
|
+
2. Repository is clean (no uncommitted changes)
|
|
2733
|
+
|
|
2734
|
+
Raises:
|
|
2735
|
+
ReleaseManagerError: If validation fails
|
|
2736
|
+
|
|
2737
|
+
Examples:
|
|
2738
|
+
# Valid state
|
|
2739
|
+
# Branch: ho-prod
|
|
2740
|
+
# Status: clean
|
|
2741
|
+
mgr._validate_production_upgrade()
|
|
2742
|
+
# → Returns without error
|
|
2743
|
+
|
|
2744
|
+
# Wrong branch
|
|
2745
|
+
# Branch: ho-patch/456-test
|
|
2746
|
+
mgr._validate_production_upgrade()
|
|
2747
|
+
# → Raises: "Must be on ho-prod branch"
|
|
2748
|
+
|
|
2749
|
+
# Uncommitted changes
|
|
2750
|
+
# Branch: ho-prod
|
|
2751
|
+
# Status: modified files
|
|
2752
|
+
mgr._validate_production_upgrade()
|
|
2753
|
+
# → Raises: "Repository has uncommitted changes"
|
|
2754
|
+
"""
|
|
2755
|
+
from half_orm_dev.release_manager import ReleaseManagerError
|
|
2756
|
+
|
|
2757
|
+
# Check branch
|
|
2758
|
+
if self._repo.hgit.branch != "ho-prod":
|
|
2759
|
+
raise ReleaseManagerError(
|
|
2760
|
+
f"Must be on ho-prod branch for production upgrade. "
|
|
2761
|
+
f"Current branch: {self._repo.hgit.branch}"
|
|
2762
|
+
)
|
|
2763
|
+
|
|
2764
|
+
# Check repo is clean
|
|
2765
|
+
if not self._repo.hgit.repos_is_clean():
|
|
2766
|
+
raise ReleaseManagerError(
|
|
2767
|
+
"Repository has uncommitted changes. "
|
|
2768
|
+
"Commit or stash changes before upgrading production."
|
|
2769
|
+
)
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
def _apply_release_to_production(self, version: str) -> List[str]:
|
|
2773
|
+
"""
|
|
2774
|
+
Apply single release to existing production database.
|
|
2775
|
+
|
|
2776
|
+
Reads patches from releases/{version}.txt and applies them sequentially
|
|
2777
|
+
to the existing database using PatchManager.apply_patch_files().
|
|
2778
|
+
Updates database version after successful application.
|
|
2779
|
+
|
|
2780
|
+
CRITICAL: Works on EXISTING database. Does NOT restore/recreate.
|
|
2781
|
+
|
|
2782
|
+
Args:
|
|
2783
|
+
version: Release version (e.g., "1.3.6")
|
|
2784
|
+
|
|
2785
|
+
Returns:
|
|
2786
|
+
List[str]: Patch IDs applied (e.g., ["456-auth", "789-security"])
|
|
2787
|
+
|
|
2788
|
+
Raises:
|
|
2789
|
+
ReleaseManagerError: If patch application fails
|
|
2790
|
+
|
|
2791
|
+
Examples:
|
|
2792
|
+
# Apply release with multiple patches
|
|
2793
|
+
# releases/1.3.6.txt contains: 456-auth, 789-security
|
|
2794
|
+
patches = mgr._apply_release_to_production("1.3.6")
|
|
2795
|
+
# → Applies 456-auth to existing DB
|
|
2796
|
+
# → Applies 789-security to existing DB
|
|
2797
|
+
# → Updates DB version to 1.3.6
|
|
2798
|
+
# → Returns ["456-auth", "789-security"]
|
|
2799
|
+
|
|
2800
|
+
# Apply release with no patches (empty release)
|
|
2801
|
+
# releases/1.3.6.txt is empty
|
|
2802
|
+
patches = mgr._apply_release_to_production("1.3.6")
|
|
2803
|
+
# → Updates DB version to 1.3.6
|
|
2804
|
+
# → Returns []
|
|
2805
|
+
|
|
2806
|
+
# Patch application fails
|
|
2807
|
+
# 789-security has SQL error
|
|
2808
|
+
patches = mgr._apply_release_to_production("1.3.6")
|
|
2809
|
+
# → Applies 456-auth successfully
|
|
2810
|
+
# → 789-security fails
|
|
2811
|
+
# → Raises exception with error details
|
|
2812
|
+
"""
|
|
2813
|
+
from half_orm_dev.release_manager import ReleaseManagerError
|
|
2814
|
+
|
|
2815
|
+
# Read patches from release file
|
|
2816
|
+
release_file = f"{version}.txt"
|
|
2817
|
+
patches = self.read_release_patches(release_file)
|
|
2818
|
+
|
|
2819
|
+
# Apply each patch sequentially
|
|
2820
|
+
for patch_id in patches:
|
|
2821
|
+
try:
|
|
2822
|
+
self._repo.patch_manager.apply_patch_files(
|
|
2823
|
+
patch_id,
|
|
2824
|
+
self._repo.model
|
|
2825
|
+
)
|
|
2826
|
+
except Exception as e:
|
|
2827
|
+
raise ReleaseManagerError(
|
|
2828
|
+
f"Failed to apply patch {patch_id} from release {version}: {e}"
|
|
2829
|
+
) from e
|
|
2830
|
+
|
|
2831
|
+
# Update database version
|
|
2832
|
+
version_parts = version.split('.')
|
|
2833
|
+
if len(version_parts) != 3:
|
|
2834
|
+
raise ReleaseManagerError(
|
|
2835
|
+
f"Invalid version format: {version}. Expected X.Y.Z"
|
|
2836
|
+
)
|
|
2837
|
+
|
|
2838
|
+
major, minor, patch = map(int, version_parts)
|
|
2839
|
+
self._repo.database.register_release(major, minor, patch)
|
|
2840
|
+
|
|
2841
|
+
return patches
|