rlsbl 0.0.1 → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/bin/cli.js +16 -0
- package/package.json +22 -3
- package/rlsbl/__init__.py +174 -1
- package/rlsbl/__main__.py +4 -0
- package/rlsbl/commands/__init__.py +0 -0
- package/rlsbl/commands/check_name.py +184 -0
- package/rlsbl/commands/init_cmd.py +321 -0
- package/rlsbl/commands/release.py +179 -0
- package/rlsbl/commands/status.py +76 -0
- package/rlsbl/registries/__init__.py +5 -0
- package/rlsbl/registries/npm.py +120 -0
- package/rlsbl/registries/pypi.py +172 -0
- package/rlsbl/utils.py +124 -0
- package/templates/npm/ci.yml.tpl +22 -0
- package/templates/npm/publish.yml.tpl +22 -0
- package/templates/pypi/ci.yml.tpl +20 -0
- package/templates/pypi/publish.yml.tpl +18 -0
- package/templates/shared/CHANGELOG.md.tpl +5 -0
- package/templates/shared/CLAUDE.md.tpl +20 -0
- package/templates/shared/LICENSE.tpl +21 -0
- package/templates/shared/check-prs.sh.tpl +10 -0
- package/templates/shared/claude-settings.json.tpl +15 -0
- package/templates/shared/gitignore.tpl +13 -0
- package/templates/shared/pre-push-hook.sh.tpl +42 -0
- package/templates/shared/pre-release.sh.tpl +13 -0
- package/templates/shared/record-gif.sh.tpl +34 -0
- package/pyproject.toml +0 -11
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Init command: scaffold release infrastructure from templates."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from ..registries import REGISTRIES
|
|
12
|
+
|
|
13
|
+
HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
|
|
14
|
+
|
|
15
|
+
# Files where existing content is preserved and template sections are appended
|
|
16
|
+
APPENDABLE = {"CLAUDE.md"}
|
|
17
|
+
APPEND_MARKER = "rlsbl"
|
|
18
|
+
|
|
19
|
+
# Files where missing entries from the template are merged into the existing file
|
|
20
|
+
MERGEABLE = {".gitignore"}
|
|
21
|
+
|
|
22
|
+
# Files that are safe to overwrite during --update (managed files users typically don't customize)
|
|
23
|
+
UPDATABLE = {
|
|
24
|
+
".github/workflows/ci.yml",
|
|
25
|
+
".github/workflows/publish.yml",
|
|
26
|
+
"scripts/check-prs.sh",
|
|
27
|
+
"scripts/pre-push-hook.sh",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def file_hash(path):
|
|
31
|
+
"""SHA-256 hash of a file's contents."""
|
|
32
|
+
with open(path, "rb") as f:
|
|
33
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_hashes():
|
|
37
|
+
"""Load stored file hashes from .rlsbl/hashes.json."""
|
|
38
|
+
if os.path.exists(HASHES_FILE):
|
|
39
|
+
with open(HASHES_FILE) as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_hashes(hashes):
|
|
45
|
+
"""Write file hashes to .rlsbl/hashes.json."""
|
|
46
|
+
os.makedirs(os.path.dirname(HASHES_FILE), exist_ok=True)
|
|
47
|
+
with open(HASHES_FILE, "w") as f:
|
|
48
|
+
json.dump(hashes, f, indent=2)
|
|
49
|
+
f.write("\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NEXT_STEPS = {
|
|
53
|
+
"npm": [
|
|
54
|
+
"Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)",
|
|
55
|
+
"Push to GitHub to activate the CI workflow",
|
|
56
|
+
"Run rlsbl npm release [patch|minor|major]",
|
|
57
|
+
],
|
|
58
|
+
"pypi": [
|
|
59
|
+
"Push to GitHub",
|
|
60
|
+
"Configure Trusted Publishing on pypi.org",
|
|
61
|
+
"Run rlsbl pypi release [patch|minor|major]",
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def process_template(template_content, vars_dict):
|
|
67
|
+
"""Process a template string by replacing {{varName}} placeholders with values.
|
|
68
|
+
|
|
69
|
+
Returns (content, unreplaced) where unreplaced is a list of unmatched var names.
|
|
70
|
+
"""
|
|
71
|
+
unreplaced = []
|
|
72
|
+
|
|
73
|
+
def replacer(match):
|
|
74
|
+
var_name = match.group(1)
|
|
75
|
+
if var_name in vars_dict:
|
|
76
|
+
return vars_dict[var_name]
|
|
77
|
+
unreplaced.append(var_name)
|
|
78
|
+
return match.group(0)
|
|
79
|
+
|
|
80
|
+
content = re.sub(r"\{\{(\w+)\}\}", replacer, template_content)
|
|
81
|
+
return content, unreplaced
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
85
|
+
existing_hashes=None):
|
|
86
|
+
"""Process a list of template mappings: read each template, apply vars, write target files.
|
|
87
|
+
|
|
88
|
+
Skips existing files unless force is True, with special handling:
|
|
89
|
+
- APPENDABLE files: append template sections if the marker is not already present
|
|
90
|
+
- MERGEABLE files: merge missing entries from the template into the existing file
|
|
91
|
+
- UPDATABLE files (with --update): overwrite only if the file hasn't been customized
|
|
92
|
+
(detected via SHA-256 hash comparison against stored hashes)
|
|
93
|
+
|
|
94
|
+
Returns (created, skipped, warnings, new_hashes).
|
|
95
|
+
"""
|
|
96
|
+
if existing_hashes is None:
|
|
97
|
+
existing_hashes = {}
|
|
98
|
+
created = []
|
|
99
|
+
skipped = []
|
|
100
|
+
warnings = []
|
|
101
|
+
new_hashes = {}
|
|
102
|
+
|
|
103
|
+
for mapping in mappings:
|
|
104
|
+
template = mapping["template"]
|
|
105
|
+
target = mapping["target"]
|
|
106
|
+
|
|
107
|
+
template_path = os.path.join(template_dir, template)
|
|
108
|
+
if not os.path.exists(template_path):
|
|
109
|
+
warnings.append(f"Template not found: {template_path}")
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# When file exists and force is not set, use context-aware handling
|
|
113
|
+
if os.path.exists(target) and not force:
|
|
114
|
+
basename = os.path.basename(target)
|
|
115
|
+
|
|
116
|
+
# In --update mode, overwrite managed files only if not customized
|
|
117
|
+
if update and target in UPDATABLE:
|
|
118
|
+
current_hash = file_hash(target)
|
|
119
|
+
stored_hash = existing_hashes.get(target)
|
|
120
|
+
if stored_hash and current_hash == stored_hash:
|
|
121
|
+
# File matches stored hash -- not customized, safe to overwrite
|
|
122
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
123
|
+
raw = f.read()
|
|
124
|
+
content, unreplaced = process_template(raw, vars_dict)
|
|
125
|
+
target_dir = os.path.dirname(target)
|
|
126
|
+
if target_dir and target_dir != ".":
|
|
127
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
128
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
129
|
+
f.write(content)
|
|
130
|
+
new_hashes[target] = file_hash(target)
|
|
131
|
+
created.append(target + " (updated)")
|
|
132
|
+
if unreplaced:
|
|
133
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
134
|
+
else:
|
|
135
|
+
# File was customized or no stored hash -- skip conservatively
|
|
136
|
+
# Seed the hash so future --update can detect changes
|
|
137
|
+
new_hashes[target] = current_hash
|
|
138
|
+
skipped.append(f"{target} (customized, use --force to overwrite)")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if basename in APPENDABLE:
|
|
142
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
143
|
+
existing = f.read()
|
|
144
|
+
if APPEND_MARKER in existing:
|
|
145
|
+
skipped.append(target + " (already has rlsbl section)")
|
|
146
|
+
continue
|
|
147
|
+
# Append only the ## sections, stripping the top-level # heading
|
|
148
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
149
|
+
raw = f.read()
|
|
150
|
+
content, _ = process_template(raw, vars_dict)
|
|
151
|
+
lines = content.split("\n")
|
|
152
|
+
first_section_idx = None
|
|
153
|
+
for i, line in enumerate(lines):
|
|
154
|
+
if i > 0 and line.startswith("## "):
|
|
155
|
+
first_section_idx = i
|
|
156
|
+
break
|
|
157
|
+
section = "\n".join(lines[first_section_idx:]) if first_section_idx is not None else content
|
|
158
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
159
|
+
f.write("\n\n" + section.strip() + "\n")
|
|
160
|
+
created.append(target + " (appended)")
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if basename in MERGEABLE:
|
|
164
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
165
|
+
existing = f.read()
|
|
166
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
167
|
+
raw = f.read()
|
|
168
|
+
content, _ = process_template(raw, vars_dict)
|
|
169
|
+
existing_lines = {
|
|
170
|
+
line.strip() for line in existing.split("\n") if line.strip()
|
|
171
|
+
}
|
|
172
|
+
new_lines = [
|
|
173
|
+
line.strip() for line in content.split("\n") if line.strip()
|
|
174
|
+
]
|
|
175
|
+
# Only merge non-comment entries that are missing from the existing file
|
|
176
|
+
missing = [
|
|
177
|
+
line for line in new_lines
|
|
178
|
+
if line not in existing_lines and not line.startswith("#")
|
|
179
|
+
]
|
|
180
|
+
if missing:
|
|
181
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
182
|
+
f.write("\n# Added by rlsbl\n" + "\n".join(missing) + "\n")
|
|
183
|
+
created.append(f"{target} (merged {len(missing)} entries)")
|
|
184
|
+
else:
|
|
185
|
+
skipped.append(target + " (all entries present)")
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
skipped.append(target)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
192
|
+
raw = f.read()
|
|
193
|
+
content, unreplaced = process_template(raw, vars_dict)
|
|
194
|
+
|
|
195
|
+
# Ensure parent directory exists
|
|
196
|
+
target_dir = os.path.dirname(target)
|
|
197
|
+
if target_dir and target_dir != ".":
|
|
198
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
199
|
+
|
|
200
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
201
|
+
f.write(content)
|
|
202
|
+
new_hashes[target] = file_hash(target)
|
|
203
|
+
created.append(target)
|
|
204
|
+
|
|
205
|
+
if unreplaced:
|
|
206
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
207
|
+
|
|
208
|
+
return created, skipped, warnings, new_hashes
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_cmd(registry, args, flags):
|
|
212
|
+
"""Init command handler.
|
|
213
|
+
|
|
214
|
+
Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
|
|
215
|
+
from templates.
|
|
216
|
+
"""
|
|
217
|
+
reg = REGISTRIES[registry]
|
|
218
|
+
|
|
219
|
+
# Check that a project file exists
|
|
220
|
+
if not reg.check_project_exists("."):
|
|
221
|
+
print(f"Error: no {registry} project found in current directory.", file=sys.stderr)
|
|
222
|
+
print(reg.get_project_init_hint(), file=sys.stderr)
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
|
|
225
|
+
# Gather template variables
|
|
226
|
+
vars_dict = reg.get_template_vars(".")
|
|
227
|
+
from datetime import datetime
|
|
228
|
+
vars_dict["year"] = str(datetime.now().year)
|
|
229
|
+
|
|
230
|
+
force = flags.get("force", False)
|
|
231
|
+
update = flags.get("update", False)
|
|
232
|
+
|
|
233
|
+
existing_hashes = load_hashes()
|
|
234
|
+
|
|
235
|
+
# Process registry-specific templates
|
|
236
|
+
reg_created, reg_skipped, reg_warnings, reg_hashes = process_mappings(
|
|
237
|
+
reg.get_template_dir(),
|
|
238
|
+
reg.get_template_mappings(),
|
|
239
|
+
vars_dict,
|
|
240
|
+
force,
|
|
241
|
+
update,
|
|
242
|
+
existing_hashes,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Process shared templates (skip if another registry already handled them)
|
|
246
|
+
shared_created, shared_skipped, shared_warnings, shared_hashes = [], [], [], {}
|
|
247
|
+
if not flags.get("skip-shared"):
|
|
248
|
+
shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
|
|
249
|
+
reg.get_shared_template_dir(),
|
|
250
|
+
reg.get_shared_template_mappings(),
|
|
251
|
+
vars_dict,
|
|
252
|
+
force,
|
|
253
|
+
update,
|
|
254
|
+
existing_hashes,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Make all shell scripts in scripts/ executable
|
|
258
|
+
scripts_dir = os.path.join(".", "scripts")
|
|
259
|
+
if os.path.isdir(scripts_dir):
|
|
260
|
+
for entry in os.listdir(scripts_dir):
|
|
261
|
+
if entry.endswith(".sh"):
|
|
262
|
+
os.chmod(os.path.join(scripts_dir, entry), 0o755)
|
|
263
|
+
|
|
264
|
+
# Auto-install pre-push hook if not already present
|
|
265
|
+
hook_source = os.path.join("scripts", "pre-push-hook.sh")
|
|
266
|
+
hook_target = os.path.join(".git", "hooks", "pre-push")
|
|
267
|
+
if os.path.exists(hook_source) and os.path.isdir(".git"):
|
|
268
|
+
if not os.path.exists(hook_target):
|
|
269
|
+
os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
|
|
270
|
+
shutil.copy2(hook_source, hook_target)
|
|
271
|
+
os.chmod(hook_target, 0o755)
|
|
272
|
+
print("Installed pre-push hook (.git/hooks/pre-push)")
|
|
273
|
+
|
|
274
|
+
# Write scaffolding version marker so the pre-push hook can detect drift
|
|
275
|
+
from rlsbl import __version__
|
|
276
|
+
marker_dir = os.path.join(".", ".rlsbl")
|
|
277
|
+
os.makedirs(marker_dir, exist_ok=True)
|
|
278
|
+
marker_path = os.path.join(marker_dir, "version")
|
|
279
|
+
with open(marker_path, "w") as f:
|
|
280
|
+
f.write(__version__ + "\n")
|
|
281
|
+
print("Wrote scaffolding version marker (.rlsbl/version)")
|
|
282
|
+
|
|
283
|
+
# Persist file hashes for future --update customization detection
|
|
284
|
+
all_new_hashes = {}
|
|
285
|
+
all_new_hashes.update(reg_hashes)
|
|
286
|
+
all_new_hashes.update(shared_hashes)
|
|
287
|
+
existing_hashes.update(all_new_hashes)
|
|
288
|
+
save_hashes(existing_hashes)
|
|
289
|
+
|
|
290
|
+
# Merge results
|
|
291
|
+
created = reg_created + shared_created
|
|
292
|
+
skipped = reg_skipped + shared_skipped
|
|
293
|
+
warnings = reg_warnings + shared_warnings
|
|
294
|
+
|
|
295
|
+
# Print summary
|
|
296
|
+
if created:
|
|
297
|
+
print("Created:")
|
|
298
|
+
for f in created:
|
|
299
|
+
print(f" {f}")
|
|
300
|
+
|
|
301
|
+
if skipped:
|
|
302
|
+
print("Skipped (already exist, use --update to refresh managed files or --force to overwrite all):")
|
|
303
|
+
for f in skipped:
|
|
304
|
+
print(f" {f}")
|
|
305
|
+
|
|
306
|
+
if warnings:
|
|
307
|
+
print("Warnings:")
|
|
308
|
+
for w in warnings:
|
|
309
|
+
print(f" {w}")
|
|
310
|
+
|
|
311
|
+
# Helpful note when existing CI workflow is preserved
|
|
312
|
+
ci_path = ".github/workflows/ci.yml"
|
|
313
|
+
if any(s.startswith(ci_path) for s in skipped):
|
|
314
|
+
print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
|
|
315
|
+
|
|
316
|
+
# Next steps
|
|
317
|
+
steps = NEXT_STEPS.get(registry)
|
|
318
|
+
if steps:
|
|
319
|
+
print("\nNext steps:")
|
|
320
|
+
for i, step in enumerate(steps, 1):
|
|
321
|
+
print(f" {i}. {step}")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Release command: bump version, commit, push, create GitHub Release."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from ..registries import REGISTRIES
|
|
8
|
+
from ..utils import (
|
|
9
|
+
bump_version,
|
|
10
|
+
check_gh_auth,
|
|
11
|
+
check_gh_installed,
|
|
12
|
+
extract_changelog_entry,
|
|
13
|
+
find_commit_tool,
|
|
14
|
+
get_current_branch,
|
|
15
|
+
is_clean_tree,
|
|
16
|
+
push_if_needed,
|
|
17
|
+
run,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
VALID_BUMP_TYPES = ("patch", "minor", "major")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_cmd(registry, args, flags):
|
|
24
|
+
"""Release command handler.
|
|
25
|
+
|
|
26
|
+
Bumps version, commits, pushes, and creates a GitHub Release.
|
|
27
|
+
"""
|
|
28
|
+
quiet = flags.get("quiet", False)
|
|
29
|
+
|
|
30
|
+
def log(msg):
|
|
31
|
+
if not quiet:
|
|
32
|
+
print(msg)
|
|
33
|
+
|
|
34
|
+
reg = REGISTRIES[registry]
|
|
35
|
+
|
|
36
|
+
# Check prerequisites
|
|
37
|
+
if not check_gh_installed():
|
|
38
|
+
print("Error: gh CLI is not installed. Install it from https://cli.github.com", file=sys.stderr)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
if not check_gh_auth():
|
|
41
|
+
print('Error: gh CLI is not authenticated. Run "gh auth login" first.', file=sys.stderr)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
# Clean working tree
|
|
45
|
+
if not is_clean_tree():
|
|
46
|
+
print("Error: working tree is not clean. Commit your changes first.", file=sys.stderr)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
# Branch check
|
|
50
|
+
branch = get_current_branch()
|
|
51
|
+
if branch not in ("main", "master"):
|
|
52
|
+
print(f'Warning: you are on branch "{branch}", not main/master.', file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
# Current version
|
|
55
|
+
current_version = reg.read_version(".")
|
|
56
|
+
log(f"Current version: {current_version}")
|
|
57
|
+
|
|
58
|
+
# Bump type
|
|
59
|
+
bump_type = args[0] if args else "patch"
|
|
60
|
+
if bump_type not in VALID_BUMP_TYPES:
|
|
61
|
+
print(
|
|
62
|
+
f'Error: invalid bump type "{bump_type}". Use: {", ".join(VALID_BUMP_TYPES)}',
|
|
63
|
+
file=sys.stderr,
|
|
64
|
+
)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
# Compute new version
|
|
68
|
+
new_version = bump_version(current_version, bump_type)
|
|
69
|
+
tag = f"v{new_version}"
|
|
70
|
+
log(f"New version: {new_version} ({bump_type})")
|
|
71
|
+
|
|
72
|
+
# Check tag doesn't already exist
|
|
73
|
+
tag_output = run("git", ["tag", "-l", tag])
|
|
74
|
+
if len(tag_output) > 0:
|
|
75
|
+
print(f'Error: tag "{tag}" already exists.', file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
# Validate changelog entry
|
|
79
|
+
changelog_path = os.path.join(".", "CHANGELOG.md")
|
|
80
|
+
if not os.path.exists(changelog_path):
|
|
81
|
+
print(
|
|
82
|
+
f"Error: CHANGELOG.md not found. Create one with a ## {new_version} section.",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
changelog_entry = extract_changelog_entry(changelog_path, new_version)
|
|
87
|
+
if not changelog_entry:
|
|
88
|
+
print(
|
|
89
|
+
f"Error: no changelog entry found for version {new_version} in CHANGELOG.md.",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
print(f'Add a "## {new_version}" section describing the changes.', file=sys.stderr)
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
if len(changelog_entry.strip()) < 10:
|
|
95
|
+
print(
|
|
96
|
+
f"Warning: changelog entry for {new_version} is very short. Consider adding more detail.",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Run pre-release hook if present
|
|
101
|
+
pre_release_script = os.path.join(".", "scripts", "pre-release.sh")
|
|
102
|
+
if os.path.exists(pre_release_script):
|
|
103
|
+
log("Running pre-release hook...")
|
|
104
|
+
try:
|
|
105
|
+
run("bash", [pre_release_script])
|
|
106
|
+
except Exception:
|
|
107
|
+
print("Error: pre-release hook failed. Fix the issues and try again.", file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
# Dry run: print summary and return
|
|
111
|
+
if flags.get("dry-run", False):
|
|
112
|
+
log("\n--- Dry run summary ---")
|
|
113
|
+
log(f"Registry: {registry}")
|
|
114
|
+
log(f"Bump: {current_version} -> {new_version} ({bump_type})")
|
|
115
|
+
log(f"Tag: {tag}")
|
|
116
|
+
log(f"Branch: {branch}")
|
|
117
|
+
# Show other version files that would be synced
|
|
118
|
+
other_files = []
|
|
119
|
+
for name, other_reg in REGISTRIES.items():
|
|
120
|
+
if name == registry:
|
|
121
|
+
continue
|
|
122
|
+
if other_reg.check_project_exists("."):
|
|
123
|
+
other_files.append(other_reg.get_version_file())
|
|
124
|
+
if other_files:
|
|
125
|
+
log(f"Sync to: {', '.join(other_files)}")
|
|
126
|
+
log(f"Changelog:\n{changelog_entry}")
|
|
127
|
+
log("--- No changes made ---")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Write new version to the primary registry file
|
|
131
|
+
version_file = reg.get_version_file()
|
|
132
|
+
reg.write_version(".", new_version)
|
|
133
|
+
log(f"Updated version in {version_file}")
|
|
134
|
+
|
|
135
|
+
# Sync version to all other recognized version files
|
|
136
|
+
files_to_commit = [version_file]
|
|
137
|
+
for name, other_reg in REGISTRIES.items():
|
|
138
|
+
if name == registry:
|
|
139
|
+
continue
|
|
140
|
+
if other_reg.check_project_exists("."):
|
|
141
|
+
other_reg.write_version(".", new_version)
|
|
142
|
+
other_file = other_reg.get_version_file()
|
|
143
|
+
files_to_commit.append(other_file)
|
|
144
|
+
log(f"Synced version to {other_file}")
|
|
145
|
+
|
|
146
|
+
# Commit all bumped version files together
|
|
147
|
+
commit_tool = find_commit_tool()
|
|
148
|
+
if commit_tool == "safegit":
|
|
149
|
+
run(commit_tool, ["commit", "-m", tag, "--", *files_to_commit])
|
|
150
|
+
else:
|
|
151
|
+
run("git", ["add", *files_to_commit])
|
|
152
|
+
run("git", ["commit", "-m", tag])
|
|
153
|
+
log(f"Committed: {tag}")
|
|
154
|
+
|
|
155
|
+
# Create local git tag
|
|
156
|
+
run("git", ["tag", tag])
|
|
157
|
+
log(f"Tagged: {tag}")
|
|
158
|
+
|
|
159
|
+
# Push commits and tag
|
|
160
|
+
push_if_needed(branch)
|
|
161
|
+
run("git", ["push", "origin", tag])
|
|
162
|
+
log(f"Pushed to origin/{branch}")
|
|
163
|
+
|
|
164
|
+
# Create GitHub Release using a temp notes file
|
|
165
|
+
notes_file = f".rlsbl-notes-{int(time.time() * 1000)}.tmp"
|
|
166
|
+
writing_file = notes_file + ".writing"
|
|
167
|
+
try:
|
|
168
|
+
with open(writing_file, "w", encoding="utf-8") as f:
|
|
169
|
+
f.write(changelog_entry)
|
|
170
|
+
os.rename(writing_file, notes_file)
|
|
171
|
+
run("gh", ["release", "create", tag, "--title", tag, "--notes-file", notes_file])
|
|
172
|
+
log(f"Created GitHub Release: {tag}")
|
|
173
|
+
finally:
|
|
174
|
+
# Clean up temp files even if gh release fails
|
|
175
|
+
for tmp in (notes_file, writing_file):
|
|
176
|
+
if os.path.exists(tmp):
|
|
177
|
+
os.unlink(tmp)
|
|
178
|
+
|
|
179
|
+
log(f"\nRelease {new_version} complete!")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Status command: show project status summary."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from ..registries import REGISTRIES
|
|
7
|
+
from ..utils import (
|
|
8
|
+
extract_changelog_entry,
|
|
9
|
+
get_current_branch,
|
|
10
|
+
is_clean_tree,
|
|
11
|
+
run,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_cmd(registry, args, flags):
|
|
16
|
+
"""Status command handler.
|
|
17
|
+
|
|
18
|
+
Shows a quick 'where am I' summary: package info, git state, changelog, CI.
|
|
19
|
+
"""
|
|
20
|
+
reg = REGISTRIES[registry]
|
|
21
|
+
|
|
22
|
+
if not reg.check_project_exists("."):
|
|
23
|
+
print(f"No {registry} project found in current directory.", file=sys.stderr)
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
version = reg.read_version(".")
|
|
27
|
+
vars_dict = reg.get_template_vars(".")
|
|
28
|
+
name = vars_dict.get("name") or "(unknown)"
|
|
29
|
+
|
|
30
|
+
print(f"Package: {name}")
|
|
31
|
+
|
|
32
|
+
# Show version info for all detected registries
|
|
33
|
+
for r_name, r_mod in REGISTRIES.items():
|
|
34
|
+
if r_mod.check_project_exists("."):
|
|
35
|
+
ver = r_mod.read_version(".")
|
|
36
|
+
file = r_mod.get_version_file()
|
|
37
|
+
print(f"Version: {ver} ({r_name}, {file})")
|
|
38
|
+
|
|
39
|
+
# Git info
|
|
40
|
+
try:
|
|
41
|
+
branch = get_current_branch()
|
|
42
|
+
print(f"Branch: {branch}")
|
|
43
|
+
except Exception:
|
|
44
|
+
print("Branch: (not a git repo)")
|
|
45
|
+
|
|
46
|
+
# Last tag
|
|
47
|
+
try:
|
|
48
|
+
last_tag = run("git", ["describe", "--tags", "--abbrev=0"])
|
|
49
|
+
print(f"Last tag: {last_tag}")
|
|
50
|
+
except Exception:
|
|
51
|
+
print("Last tag: (none)")
|
|
52
|
+
|
|
53
|
+
# Clean tree
|
|
54
|
+
try:
|
|
55
|
+
print(f"Clean: {'yes' if is_clean_tree() else 'no'}")
|
|
56
|
+
except Exception:
|
|
57
|
+
print("Clean: (unknown)")
|
|
58
|
+
|
|
59
|
+
# Changelog
|
|
60
|
+
changelog_path = "CHANGELOG.md"
|
|
61
|
+
if os.path.exists(changelog_path):
|
|
62
|
+
entry = extract_changelog_entry(changelog_path, version)
|
|
63
|
+
if entry:
|
|
64
|
+
print(f"Changelog: has entry for {version}")
|
|
65
|
+
else:
|
|
66
|
+
print(f"Changelog: no entry for {version}")
|
|
67
|
+
else:
|
|
68
|
+
print("Changelog: (not found)")
|
|
69
|
+
|
|
70
|
+
# CI workflows
|
|
71
|
+
ci_exists = os.path.exists(".github/workflows/ci.yml")
|
|
72
|
+
publish_exists = os.path.exists(".github/workflows/publish.yml") or os.path.exists(
|
|
73
|
+
".github/workflows/workflow.yml"
|
|
74
|
+
)
|
|
75
|
+
print(f"CI: {'yes' if ci_exists else 'missing'}")
|
|
76
|
+
print(f"Publish: {'yes' if publish_exists else 'missing'}")
|