development-engine-vector 0.3.0__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.
- dev/__init__.py +3 -0
- dev/__main__.py +5 -0
- dev/cli/__init__.py +0 -0
- dev/cli/cli.py +674 -0
- dev/kernel/__init__.py +0 -0
- dev/kernel/config.py +46 -0
- dev/kernel/db.py +129 -0
- dev/kernel/paths.py +85 -0
- dev/kernel/pmf_kernel.py +432 -0
- dev/kernel/selfcheck.py +137 -0
- dev/ui/__init__.py +0 -0
- dev/ui/actions.py +47 -0
- dev/ui/db/__init__.py +26 -0
- dev/ui/db/base.py +75 -0
- dev/ui/db/checks.py +16 -0
- dev/ui/db/events.py +18 -0
- dev/ui/db/health.py +34 -0
- dev/ui/db/identity.py +12 -0
- dev/ui/db/manifest.py +35 -0
- dev/ui/db/overview.py +47 -0
- dev/ui/db/runs.py +47 -0
- dev/ui/routes.py +114 -0
- dev/ui/static/__init__.py +0 -0
- dev/ui/static/web.css +722 -0
- dev/ui/static/web.js +528 -0
- dev/ui/templates.py +231 -0
- dev/ui/web.py +28 -0
- dev/utils.py +93 -0
- dev/vcs/__init__.py +0 -0
- dev/vcs/git.py +107 -0
- dev/vcs/github.py +77 -0
- dev/workflow/__init__.py +0 -0
- dev/workflow/cda.py +143 -0
- dev/workflow/changelog.py +102 -0
- dev/workflow/preflight.py +307 -0
- dev/workflow/release.py +217 -0
- dev/workflow/versioning.py +87 -0
- development_engine_vector-0.3.0.dist-info/METADATA +252 -0
- development_engine_vector-0.3.0.dist-info/RECORD +41 -0
- development_engine_vector-0.3.0.dist-info/WHEEL +4 -0
- development_engine_vector-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""CHANGELOG generation and management."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dev.utils import error, success
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChangelogManager:
|
|
9
|
+
"""Manages changelog.md files."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, project_dir):
|
|
12
|
+
self.project_dir = Path(project_dir).resolve()
|
|
13
|
+
self.changelog_file = self.project_dir / "changelog.md"
|
|
14
|
+
|
|
15
|
+
if not self.changelog_file.exists():
|
|
16
|
+
error(f"changelog.md not found: {self.changelog_file}")
|
|
17
|
+
|
|
18
|
+
def read(self):
|
|
19
|
+
"""Read current CHANGELOG."""
|
|
20
|
+
return self.changelog_file.read_text()
|
|
21
|
+
|
|
22
|
+
def add_version_section(self, version, added=None, changed=None, fixed=None):
|
|
23
|
+
"""Add a new version section to CHANGELOG.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
version: Version string (e.g. "2.0.0")
|
|
27
|
+
added: List of new features
|
|
28
|
+
changed: List of changes
|
|
29
|
+
fixed: List of fixes
|
|
30
|
+
"""
|
|
31
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
32
|
+
lines = [f"## [{version}] - {today}\n"]
|
|
33
|
+
|
|
34
|
+
if added:
|
|
35
|
+
lines.append("### Added\n")
|
|
36
|
+
for item in added:
|
|
37
|
+
lines.append(f"- {item}\n")
|
|
38
|
+
lines.append("\n")
|
|
39
|
+
|
|
40
|
+
if changed:
|
|
41
|
+
lines.append("### Changed\n")
|
|
42
|
+
for item in changed:
|
|
43
|
+
lines.append(f"- {item}\n")
|
|
44
|
+
lines.append("\n")
|
|
45
|
+
|
|
46
|
+
if fixed:
|
|
47
|
+
lines.append("### Fixed\n")
|
|
48
|
+
for item in fixed:
|
|
49
|
+
lines.append(f"- {item}\n")
|
|
50
|
+
lines.append("\n")
|
|
51
|
+
|
|
52
|
+
new_section = "".join(lines)
|
|
53
|
+
|
|
54
|
+
# Read current and find insertion point (after intro)
|
|
55
|
+
text = self.read()
|
|
56
|
+
lines = text.split("\n")
|
|
57
|
+
|
|
58
|
+
# Find first version line to insert before
|
|
59
|
+
insert_idx = 0
|
|
60
|
+
for i, line in enumerate(lines):
|
|
61
|
+
if line.startswith("## ["):
|
|
62
|
+
insert_idx = i
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
# Insert new section
|
|
66
|
+
lines.insert(insert_idx, new_section)
|
|
67
|
+
new_text = "\n".join(lines)
|
|
68
|
+
self.changelog_file.write_text(new_text)
|
|
69
|
+
success(f"Added changelog entry for {version}")
|
|
70
|
+
|
|
71
|
+
def verify_has_entry(self, version):
|
|
72
|
+
"""Check if version has a changelog entry."""
|
|
73
|
+
text = self.read()
|
|
74
|
+
return f"## [{version}]" in text
|
|
75
|
+
|
|
76
|
+
def extract_entries(self, version):
|
|
77
|
+
"""Extract Added/Changed/Fixed entries for a version."""
|
|
78
|
+
text = self.read()
|
|
79
|
+
# Simple extraction - find version section and parse
|
|
80
|
+
start = text.find(f"## [{version}]")
|
|
81
|
+
if start == -1:
|
|
82
|
+
return {"added": [], "changed": [], "fixed": []}
|
|
83
|
+
|
|
84
|
+
# Find next version or end
|
|
85
|
+
next_version = text.find("## [", start + 1)
|
|
86
|
+
section = text[start : next_version if next_version != -1 else len(text)]
|
|
87
|
+
|
|
88
|
+
result = {"added": [], "changed": [], "fixed": []}
|
|
89
|
+
current_section = None
|
|
90
|
+
|
|
91
|
+
for line in section.split("\n"):
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if line.startswith("### Added"):
|
|
94
|
+
current_section = "added"
|
|
95
|
+
elif line.startswith("### Changed"):
|
|
96
|
+
current_section = "changed"
|
|
97
|
+
elif line.startswith("### Fixed"):
|
|
98
|
+
current_section = "fixed"
|
|
99
|
+
elif line.startswith("- ") and current_section:
|
|
100
|
+
result[current_section].append(line[2:])
|
|
101
|
+
|
|
102
|
+
return result
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Pre-flight validation checks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from dev.utils import error, info, yellow
|
|
11
|
+
|
|
12
|
+
# Project-specific preflight modules — each must define:
|
|
13
|
+
# is_applicable(project_dir) -> bool
|
|
14
|
+
# run(project_dir) -> list[dict] (list of check result dicts)
|
|
15
|
+
try:
|
|
16
|
+
from dev.workflow.cda import CdaPreflightModule
|
|
17
|
+
PREFLIGHT_MODULES = [CdaPreflightModule]
|
|
18
|
+
except ImportError:
|
|
19
|
+
PREFLIGHT_MODULES = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PreflightChecks:
|
|
23
|
+
"""Validates project state before release."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, project_dir):
|
|
26
|
+
self.project_dir = Path(project_dir).resolve()
|
|
27
|
+
self.issues = []
|
|
28
|
+
self.checks = []
|
|
29
|
+
|
|
30
|
+
def _record_check(self, name, passed, message=None, details=None):
|
|
31
|
+
item = {
|
|
32
|
+
"name": name,
|
|
33
|
+
"passed": passed,
|
|
34
|
+
"message": message or ("OK" if passed else "Failed"),
|
|
35
|
+
}
|
|
36
|
+
if details is not None:
|
|
37
|
+
item["details"] = details
|
|
38
|
+
self.checks.append(item)
|
|
39
|
+
|
|
40
|
+
if not passed and message:
|
|
41
|
+
self.issues.append(message)
|
|
42
|
+
|
|
43
|
+
def run_all(self, full=False, report_path=None):
|
|
44
|
+
"""Run all pre-flight checks."""
|
|
45
|
+
self.issues = []
|
|
46
|
+
self.checks = []
|
|
47
|
+
|
|
48
|
+
self.check_git_status()
|
|
49
|
+
self.check_version_file()
|
|
50
|
+
self.check_changelog()
|
|
51
|
+
self.check_pyproject()
|
|
52
|
+
self.check_readme()
|
|
53
|
+
self.check_license()
|
|
54
|
+
self.check_requirements()
|
|
55
|
+
self.check_local_build_directory()
|
|
56
|
+
self.check_sensitive_files()
|
|
57
|
+
|
|
58
|
+
if full:
|
|
59
|
+
self.check_branch("main")
|
|
60
|
+
self.check_git_remote()
|
|
61
|
+
self.check_package_build()
|
|
62
|
+
|
|
63
|
+
# Run project-specific modules
|
|
64
|
+
for module in PREFLIGHT_MODULES:
|
|
65
|
+
if module.is_applicable(self.project_dir):
|
|
66
|
+
extra = module.run(self.project_dir)
|
|
67
|
+
for check in extra:
|
|
68
|
+
self.checks.append(check)
|
|
69
|
+
if not check.get("passed"):
|
|
70
|
+
self.issues.append(check.get("message", "Unknown failure"))
|
|
71
|
+
|
|
72
|
+
if report_path:
|
|
73
|
+
self.write_report(report_path, full)
|
|
74
|
+
|
|
75
|
+
if self.issues:
|
|
76
|
+
print(yellow("\n⚠ Pre-flight check failures:\n"))
|
|
77
|
+
for issue in self.issues:
|
|
78
|
+
print(f" • {issue}")
|
|
79
|
+
error("Fix issues before proceeding")
|
|
80
|
+
|
|
81
|
+
info("All pre-flight checks passed.")
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def check_git_status(self):
|
|
85
|
+
"""Verify no uncommitted changes."""
|
|
86
|
+
try:
|
|
87
|
+
output = subprocess.run(
|
|
88
|
+
["git", "status", "--porcelain"],
|
|
89
|
+
cwd=self.project_dir,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
check=True,
|
|
93
|
+
).stdout.strip()
|
|
94
|
+
|
|
95
|
+
if output:
|
|
96
|
+
self._record_check(
|
|
97
|
+
"git_status",
|
|
98
|
+
False,
|
|
99
|
+
"Uncommitted changes in git (run git status)",
|
|
100
|
+
output,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
self._record_check("git_status", True, "Clean git working tree")
|
|
104
|
+
|
|
105
|
+
except subprocess.CalledProcessError:
|
|
106
|
+
self._record_check("git_status", False, "Not a git repository or git unavailable")
|
|
107
|
+
|
|
108
|
+
def check_branch(self, expected="main"):
|
|
109
|
+
"""Verify current branch name."""
|
|
110
|
+
try:
|
|
111
|
+
branch = subprocess.run(
|
|
112
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
113
|
+
cwd=self.project_dir,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
check=True,
|
|
117
|
+
).stdout.strip()
|
|
118
|
+
|
|
119
|
+
if branch != expected:
|
|
120
|
+
self._record_check(
|
|
121
|
+
"git_branch",
|
|
122
|
+
False,
|
|
123
|
+
f"Current branch is '{branch}'; expected '{expected}'",
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
self._record_check("git_branch", True, f"On branch '{branch}'")
|
|
127
|
+
|
|
128
|
+
except subprocess.CalledProcessError:
|
|
129
|
+
self._record_check("git_branch", False, "Unable to determine git branch")
|
|
130
|
+
|
|
131
|
+
def check_git_remote(self):
|
|
132
|
+
"""Verify origin remote exists."""
|
|
133
|
+
try:
|
|
134
|
+
remote = subprocess.run(
|
|
135
|
+
["git", "remote", "get-url", "origin"],
|
|
136
|
+
cwd=self.project_dir,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
check=True,
|
|
140
|
+
).stdout.strip()
|
|
141
|
+
self._record_check("git_remote", True, f"Origin remote configured: {remote}")
|
|
142
|
+
except subprocess.CalledProcessError:
|
|
143
|
+
self._record_check("git_remote", False, "Git origin remote is not configured")
|
|
144
|
+
|
|
145
|
+
def check_version_file(self):
|
|
146
|
+
"""Verify VERSION file exists and is valid."""
|
|
147
|
+
version_file = self.project_dir / "version"
|
|
148
|
+
if not version_file.exists():
|
|
149
|
+
self._record_check("version_file", False, "version file not found")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
text = version_file.read_text().strip()
|
|
153
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+", text):
|
|
154
|
+
self._record_check("version_file", False, f"Invalid version format: {text}")
|
|
155
|
+
else:
|
|
156
|
+
self._record_check("version_file", True, f"Version file is valid: {text}")
|
|
157
|
+
|
|
158
|
+
def check_changelog(self):
|
|
159
|
+
"""Verify CHANGELOG.md exists."""
|
|
160
|
+
changelog = self.project_dir / "changelog.md"
|
|
161
|
+
if not changelog.exists():
|
|
162
|
+
self._record_check("changelog", False, "changelog.md not found")
|
|
163
|
+
else:
|
|
164
|
+
self._record_check("changelog", True, "changelog.md exists")
|
|
165
|
+
|
|
166
|
+
def check_pyproject(self):
|
|
167
|
+
"""Verify pyproject.toml exists and parses."""
|
|
168
|
+
pyproject = self.project_dir / "pyproject.toml"
|
|
169
|
+
if not pyproject.exists():
|
|
170
|
+
self._record_check("pyproject", False, "pyproject.toml not found")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
try:
|
|
175
|
+
import tomllib # type: ignore[import-not-found]
|
|
176
|
+
except ModuleNotFoundError:
|
|
177
|
+
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
178
|
+
|
|
179
|
+
tomllib.loads(pyproject.read_text())
|
|
180
|
+
self._record_check("pyproject", True, "pyproject.toml is valid")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
self._record_check("pyproject", False, f"pyproject.toml is invalid: {e}")
|
|
183
|
+
|
|
184
|
+
def check_readme(self):
|
|
185
|
+
"""Verify README exists."""
|
|
186
|
+
readme = self.project_dir / "readme.md"
|
|
187
|
+
if not readme.exists():
|
|
188
|
+
self._record_check("readme", False, "readme.md not found")
|
|
189
|
+
else:
|
|
190
|
+
self._record_check("readme", True, "readme.md exists")
|
|
191
|
+
|
|
192
|
+
def check_license(self):
|
|
193
|
+
"""Verify LICENSE exists."""
|
|
194
|
+
license_file = self.project_dir / "license"
|
|
195
|
+
if not license_file.exists():
|
|
196
|
+
self._record_check("license", False, "license not found")
|
|
197
|
+
else:
|
|
198
|
+
self._record_check("license", True, "license exists")
|
|
199
|
+
|
|
200
|
+
def check_requirements(self):
|
|
201
|
+
"""Verify requirements files exist and are parseable."""
|
|
202
|
+
for name in ["requirements.txt", "dev-requirements.txt"]:
|
|
203
|
+
file_path = self.project_dir / name
|
|
204
|
+
if file_path.exists():
|
|
205
|
+
text = file_path.read_text().strip()
|
|
206
|
+
if not text:
|
|
207
|
+
self._record_check(name, False, f"{name} is empty")
|
|
208
|
+
else:
|
|
209
|
+
lines = [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#")]
|
|
210
|
+
self._record_check(name, True, f"{name} contains {len(lines)} entries", lines[:10])
|
|
211
|
+
else:
|
|
212
|
+
self._record_check(name, True, f"{name} not present")
|
|
213
|
+
|
|
214
|
+
def check_local_build_directory(self):
|
|
215
|
+
"""Detect local build output directories that can shadow the build backend."""
|
|
216
|
+
build_dir = self.project_dir / "build"
|
|
217
|
+
if build_dir.exists():
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
["git", "check-ignore", "-q", str(build_dir)],
|
|
220
|
+
cwd=self.project_dir,
|
|
221
|
+
capture_output=True,
|
|
222
|
+
)
|
|
223
|
+
if result.returncode == 0:
|
|
224
|
+
self._record_check("local_build_dir", True, "build/ present but properly gitignored")
|
|
225
|
+
return
|
|
226
|
+
self._record_check(
|
|
227
|
+
"local_build_dir",
|
|
228
|
+
False,
|
|
229
|
+
"Local build/ directory exists and may shadow the build backend",
|
|
230
|
+
str(build_dir),
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
self._record_check("local_build_dir", True, "No local build/ directory present")
|
|
234
|
+
|
|
235
|
+
def check_sensitive_files(self):
|
|
236
|
+
"""Warn if local secret files are present and not gitignored."""
|
|
237
|
+
env_file = self.project_dir / ".env"
|
|
238
|
+
if env_file.exists():
|
|
239
|
+
# Check if it's properly gitignored
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
["git", "check-ignore", "-q", str(env_file)],
|
|
242
|
+
cwd=self.project_dir,
|
|
243
|
+
capture_output=True,
|
|
244
|
+
)
|
|
245
|
+
if result.returncode == 0:
|
|
246
|
+
self._record_check("env_file", True, ".env present but properly gitignored")
|
|
247
|
+
else:
|
|
248
|
+
self._record_check("env_file", False, ".env exists and is NOT gitignored — risk of credential exposure")
|
|
249
|
+
else:
|
|
250
|
+
self._record_check("env_file", True, ".env not present")
|
|
251
|
+
|
|
252
|
+
def check_package_build(self):
|
|
253
|
+
"""Verify the project can build distributions."""
|
|
254
|
+
try:
|
|
255
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
256
|
+
env = os.environ.copy()
|
|
257
|
+
env.pop("PYTHONPATH", None)
|
|
258
|
+
subprocess.run(
|
|
259
|
+
[sys.executable, "-m", "build", str(self.project_dir), "--sdist", "--wheel", "--outdir", tmpdir],
|
|
260
|
+
cwd=tmpdir,
|
|
261
|
+
env=env,
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
check=True,
|
|
265
|
+
)
|
|
266
|
+
self._record_check("package_build", True, "Project builds successfully")
|
|
267
|
+
except subprocess.CalledProcessError as exc:
|
|
268
|
+
details = exc.stderr or exc.stdout
|
|
269
|
+
if "No module named build" in details or "build.__main__" in details:
|
|
270
|
+
details = (
|
|
271
|
+
"Python build backend 'build' is missing or shadowed by a local build/ directory. "
|
|
272
|
+
"Install the build package and ensure the repository root does not contain a build/ package directory."
|
|
273
|
+
)
|
|
274
|
+
self._record_check(
|
|
275
|
+
"package_build",
|
|
276
|
+
False,
|
|
277
|
+
"Project build failed",
|
|
278
|
+
details,
|
|
279
|
+
)
|
|
280
|
+
except FileNotFoundError:
|
|
281
|
+
self._record_check("package_build", False, "Python build tools not available")
|
|
282
|
+
|
|
283
|
+
def write_report(self, report_path, full=False):
|
|
284
|
+
"""Write a structured preflight report."""
|
|
285
|
+
report_path = Path(report_path)
|
|
286
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
report = {
|
|
288
|
+
"project": str(self.project_dir),
|
|
289
|
+
"full": full,
|
|
290
|
+
"summary": {
|
|
291
|
+
"passed": len(self.issues) == 0,
|
|
292
|
+
"issues": len(self.issues),
|
|
293
|
+
},
|
|
294
|
+
"checks": self.checks,
|
|
295
|
+
}
|
|
296
|
+
report_path.write_text(json.dumps(report, indent=2))
|
|
297
|
+
info(f"Preflight report written to {report_path}")
|
|
298
|
+
|
|
299
|
+
def report(self):
|
|
300
|
+
"""Report check results."""
|
|
301
|
+
if not self.issues:
|
|
302
|
+
print(" All pre-flight checks passed ✓\n")
|
|
303
|
+
return len(self.issues) == 0
|
|
304
|
+
"""Report check results."""
|
|
305
|
+
if not self.issues:
|
|
306
|
+
print(" All pre-flight checks passed ✓\n")
|
|
307
|
+
return len(self.issues) == 0
|
dev/workflow/release.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""dev.workflow.release — release orchestration for the Development Engine Vector.
|
|
2
|
+
|
|
3
|
+
All actions are recorded to dev.kernel.db for run history.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dev.utils import header, info, success, error, warn, cyan, run_command
|
|
9
|
+
from dev.kernel import db
|
|
10
|
+
from dev.vcs.git import GitOps
|
|
11
|
+
from dev.workflow.versioning import VersionManager
|
|
12
|
+
from dev.workflow.changelog import ChangelogManager
|
|
13
|
+
from dev.workflow.preflight import PreflightChecks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReleaseOrchestrator:
|
|
17
|
+
"""Coordinates the release workflow."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, project_dir):
|
|
20
|
+
self.project_dir = Path(project_dir).resolve()
|
|
21
|
+
self.git = GitOps(self.project_dir)
|
|
22
|
+
self.version = VersionManager(self.project_dir)
|
|
23
|
+
self.changelog = ChangelogManager(self.project_dir)
|
|
24
|
+
self.checks = PreflightChecks(self.project_dir)
|
|
25
|
+
|
|
26
|
+
def preflight(self):
|
|
27
|
+
"""Run pre-flight checks and verify branch state."""
|
|
28
|
+
header("Pre-flight Checks")
|
|
29
|
+
project_name = self.project_dir.name
|
|
30
|
+
run_id = db.record_run(project_name, "preflight", "running")
|
|
31
|
+
try:
|
|
32
|
+
self.checks.run_all()
|
|
33
|
+
self.ensure_main_branch()
|
|
34
|
+
db.finish_run(run_id, "pass")
|
|
35
|
+
except SystemExit:
|
|
36
|
+
db.finish_run(run_id, "fail")
|
|
37
|
+
raise
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def ensure_main_branch(self):
|
|
41
|
+
branch = self.git.get_current_branch()
|
|
42
|
+
if branch != "main":
|
|
43
|
+
warn(f"Current branch is '{branch}', but release should run from 'main'.")
|
|
44
|
+
error("Switch to main before releasing.")
|
|
45
|
+
|
|
46
|
+
def bump_and_sync(self, level, custom_version=None):
|
|
47
|
+
"""Bump version and sync to all files."""
|
|
48
|
+
header("Version Management")
|
|
49
|
+
|
|
50
|
+
if custom_version:
|
|
51
|
+
new_version = custom_version
|
|
52
|
+
info(f"Using custom version: {new_version}")
|
|
53
|
+
else:
|
|
54
|
+
old_version = self.version.read()
|
|
55
|
+
new_version = self.version.bump(level)
|
|
56
|
+
info(f"Bumping {level}: {old_version} → {new_version}")
|
|
57
|
+
|
|
58
|
+
self.version.write(new_version)
|
|
59
|
+
success(f"Updated version file to {new_version}")
|
|
60
|
+
|
|
61
|
+
targets = self.version.get_sync_targets()
|
|
62
|
+
self.version.sync_to_files(new_version, targets)
|
|
63
|
+
return new_version
|
|
64
|
+
|
|
65
|
+
def validate_changelog_entry(self, version):
|
|
66
|
+
if not self.changelog.verify_has_entry(version):
|
|
67
|
+
error(
|
|
68
|
+
f"changelog.md does not contain an entry for version {version}."
|
|
69
|
+
" Add a section before releasing."
|
|
70
|
+
)
|
|
71
|
+
success(f"Found changelog entry for {version}")
|
|
72
|
+
|
|
73
|
+
def commit_and_tag(self, version):
|
|
74
|
+
"""Commit changes and create tag."""
|
|
75
|
+
header("Git Operations")
|
|
76
|
+
|
|
77
|
+
files_to_stage = [
|
|
78
|
+
"version",
|
|
79
|
+
"changelog.md",
|
|
80
|
+
"pyproject.toml",
|
|
81
|
+
"setup.py",
|
|
82
|
+
"vscode_ark/__init__.py",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
existing = [path for path in files_to_stage if (self.project_dir / path).exists()]
|
|
86
|
+
if not existing:
|
|
87
|
+
error("No release files were found to stage.")
|
|
88
|
+
|
|
89
|
+
info("Staging files...")
|
|
90
|
+
self.git.stage_files(existing)
|
|
91
|
+
|
|
92
|
+
message = f"Release v{version}: Version bump and changelog"
|
|
93
|
+
self.git.commit(message)
|
|
94
|
+
self.git.create_tag(version, f"Release {version}")
|
|
95
|
+
|
|
96
|
+
info("Pushing to origin...")
|
|
97
|
+
self.git.push_main(push_tags=True)
|
|
98
|
+
|
|
99
|
+
def build(self):
|
|
100
|
+
"""Build distributions."""
|
|
101
|
+
header("Build")
|
|
102
|
+
project_name = self.project_dir.name
|
|
103
|
+
version = self.version.read()
|
|
104
|
+
run_id = db.record_run(project_name, "build", "running", version=version)
|
|
105
|
+
info("Building source and wheel distributions...")
|
|
106
|
+
try:
|
|
107
|
+
run_command(["python", "-m", "build", "--sdist", "--wheel"], cwd=self.project_dir)
|
|
108
|
+
db.finish_run(run_id, "pass")
|
|
109
|
+
success("Build complete")
|
|
110
|
+
except SystemExit:
|
|
111
|
+
db.finish_run(run_id, "fail")
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
def publish(self):
|
|
115
|
+
"""Publish to PyPI."""
|
|
116
|
+
header("PyPI Publish")
|
|
117
|
+
project_name = self.project_dir.name
|
|
118
|
+
version = self.version.read()
|
|
119
|
+
run_id = db.record_run(project_name, "publish", "running", version=version)
|
|
120
|
+
info(f"Publishing version {version}...")
|
|
121
|
+
try:
|
|
122
|
+
run_command(["pypi", "publish", str(self.project_dir)], cwd=self.project_dir)
|
|
123
|
+
db.finish_run(run_id, "pass")
|
|
124
|
+
except SystemExit:
|
|
125
|
+
warn("Primary publish command failed, trying fallback path...")
|
|
126
|
+
try:
|
|
127
|
+
run_command(["python", "-m", "pypi", "publish", str(self.project_dir)], cwd=self.project_dir)
|
|
128
|
+
db.finish_run(run_id, "pass")
|
|
129
|
+
except SystemExit:
|
|
130
|
+
db.finish_run(run_id, "fail")
|
|
131
|
+
raise
|
|
132
|
+
db.upsert_project(project_name, str(self.project_dir), version=version)
|
|
133
|
+
success(f"Published {version} to PyPI")
|
|
134
|
+
|
|
135
|
+
def bootstrap(self, install_editable=True, install_dependencies=True, install_dev_dependencies=False):
|
|
136
|
+
"""Bootstrap the project environment."""
|
|
137
|
+
header("Bootstrap / Sync")
|
|
138
|
+
|
|
139
|
+
if (self.project_dir / ".gitmodules").exists():
|
|
140
|
+
info("Initializing git submodules...")
|
|
141
|
+
self.git.run_git(["submodule", "update", "--init", "--recursive"])
|
|
142
|
+
success("Submodules initialized")
|
|
143
|
+
|
|
144
|
+
if install_dependencies and (self.project_dir / "requirements.txt").exists():
|
|
145
|
+
info("Installing requirements.txt...")
|
|
146
|
+
run_command(["python", "-m", "pip", "install", "-r", "requirements.txt"], cwd=self.project_dir)
|
|
147
|
+
success("Installed requirements")
|
|
148
|
+
|
|
149
|
+
if install_dev_dependencies and (self.project_dir / "dev-requirements.txt").exists():
|
|
150
|
+
info("Installing dev-requirements.txt...")
|
|
151
|
+
run_command(["python", "-m", "pip", "install", "-r", "dev-requirements.txt"], cwd=self.project_dir)
|
|
152
|
+
success("Installed dev requirements")
|
|
153
|
+
|
|
154
|
+
if install_editable and (self.project_dir / "pyproject.toml").exists():
|
|
155
|
+
info("Installing project in editable mode...")
|
|
156
|
+
run_command(["python", "-m", "pip", "install", "-e", "."], cwd=self.project_dir)
|
|
157
|
+
success("Editable install complete")
|
|
158
|
+
|
|
159
|
+
success("Bootstrap complete")
|
|
160
|
+
|
|
161
|
+
def run_tests(self):
|
|
162
|
+
header("Run Tests")
|
|
163
|
+
info("Executing pytest...")
|
|
164
|
+
run_command(["python", "-m", "pytest", "-q"], cwd=self.project_dir)
|
|
165
|
+
success("Tests passed")
|
|
166
|
+
|
|
167
|
+
def run_compile(self):
|
|
168
|
+
header("Compile Check")
|
|
169
|
+
info("Checking Python syntax across project...")
|
|
170
|
+
run_command(["python", "-m", "compileall", "-q", "."], cwd=self.project_dir)
|
|
171
|
+
success("Compilation check passed")
|
|
172
|
+
|
|
173
|
+
def run_lint(self):
|
|
174
|
+
header("Lint")
|
|
175
|
+
info("Running ruff if available...")
|
|
176
|
+
try:
|
|
177
|
+
run_command(["python", "-m", "ruff", "check", "."], cwd=self.project_dir)
|
|
178
|
+
success("Lint check passed")
|
|
179
|
+
except SystemExit:
|
|
180
|
+
warn("ruff is not installed or lint failed. Install ruff to enable lint checks.")
|
|
181
|
+
|
|
182
|
+
def full_release(self, level=None, version=None, skip_build=False, skip_publish=False, dry_run=False):
|
|
183
|
+
"""Execute full release workflow."""
|
|
184
|
+
header("Full Release Workflow")
|
|
185
|
+
project_name = self.project_dir.name
|
|
186
|
+
|
|
187
|
+
self.preflight()
|
|
188
|
+
new_version = self.bump_and_sync(level, version)
|
|
189
|
+
self.validate_changelog_entry(new_version)
|
|
190
|
+
|
|
191
|
+
if dry_run:
|
|
192
|
+
info("Dry run enabled; skipping commit, build, and publish steps.")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
run_id = db.record_run(project_name, "release", "running", version=new_version)
|
|
196
|
+
try:
|
|
197
|
+
self.commit_and_tag(new_version)
|
|
198
|
+
if not skip_build:
|
|
199
|
+
self.build()
|
|
200
|
+
if not skip_publish:
|
|
201
|
+
self.publish()
|
|
202
|
+
db.finish_run(run_id, "pass")
|
|
203
|
+
except SystemExit:
|
|
204
|
+
db.finish_run(run_id, "fail")
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
header("Release Complete")
|
|
208
|
+
info(f"Version {cyan(new_version)} is now live on PyPI")
|
|
209
|
+
print()
|
|
210
|
+
|
|
211
|
+
def sync_versions_only(self):
|
|
212
|
+
"""Just sync version without release."""
|
|
213
|
+
header("Version Sync")
|
|
214
|
+
version = self.version.read()
|
|
215
|
+
targets = self.version.get_sync_targets()
|
|
216
|
+
self.version.sync_to_files(version, targets)
|
|
217
|
+
success(f"Synced version {version} to all files")
|