rlsbl 0.8.2 → 0.9.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/package.json +3 -5
- package/rlsbl/__init__.py +0 -247
- package/rlsbl/__main__.py +0 -4
- package/rlsbl/commands/__init__.py +0 -0
- package/rlsbl/commands/check.py +0 -229
- package/rlsbl/commands/config.py +0 -67
- package/rlsbl/commands/discover.py +0 -198
- package/rlsbl/commands/init_cmd.py +0 -518
- package/rlsbl/commands/pre_push_check.py +0 -46
- package/rlsbl/commands/record_gif.py +0 -92
- package/rlsbl/commands/release.py +0 -287
- package/rlsbl/commands/status.py +0 -76
- package/rlsbl/commands/undo.py +0 -74
- package/rlsbl/commands/watch.py +0 -122
- package/rlsbl/config.py +0 -57
- package/rlsbl/registries/__init__.py +0 -5
- package/rlsbl/registries/go.py +0 -123
- package/rlsbl/registries/npm.py +0 -119
- package/rlsbl/registries/pypi.py +0 -171
- package/rlsbl/tagging.py +0 -207
- package/rlsbl/templates/go/VERSION.tpl +0 -1
- package/rlsbl/templates/go/ci.yml.tpl +0 -21
- package/rlsbl/templates/go/goreleaser.yml.tpl +0 -25
- package/rlsbl/templates/go/publish.yml.tpl +0 -25
- package/rlsbl/templates/merged/publish.yml.tpl +0 -30
- package/rlsbl/templates/npm/ci.yml.tpl +0 -22
- package/rlsbl/templates/npm/publish.yml.tpl +0 -22
- package/rlsbl/templates/pypi/ci.yml.tpl +0 -20
- package/rlsbl/templates/pypi/publish.yml.tpl +0 -18
- package/rlsbl/templates/shared/CHANGELOG.md.tpl +0 -5
- package/rlsbl/templates/shared/CLAUDE.md.tpl +0 -20
- package/rlsbl/templates/shared/LICENSE.tpl +0 -21
- package/rlsbl/templates/shared/claude-settings.json.tpl +0 -3
- package/rlsbl/templates/shared/gitignore.tpl +0 -14
- package/rlsbl/templates/shared/hooks/post-release.sh.tpl +0 -8
- package/rlsbl/templates/shared/hooks/pre-release.sh.tpl +0 -31
- package/rlsbl/utils.py +0 -131
|
@@ -1,518 +0,0 @@
|
|
|
1
|
-
"""Init command: scaffold release infrastructure from templates."""
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
import tempfile
|
|
10
|
-
|
|
11
|
-
from ..config import should_tag
|
|
12
|
-
from ..registries import REGISTRIES
|
|
13
|
-
from ..tagging import ensure_tags
|
|
14
|
-
|
|
15
|
-
HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
|
|
16
|
-
BASES_DIR = os.path.join(".rlsbl", "bases")
|
|
17
|
-
|
|
18
|
-
# Files owned by the user after initial scaffold -- never overwrite or merge
|
|
19
|
-
USER_OWNED = {
|
|
20
|
-
"CHANGELOG.md",
|
|
21
|
-
"LICENSE",
|
|
22
|
-
".rlsbl/hooks/pre-release.sh",
|
|
23
|
-
".rlsbl/hooks/post-release.sh",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
def file_hash(path):
|
|
27
|
-
"""SHA-256 hash of a file's contents."""
|
|
28
|
-
with open(path, "rb") as f:
|
|
29
|
-
return hashlib.sha256(f.read()).hexdigest()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def load_hashes():
|
|
33
|
-
"""Load stored file hashes from .rlsbl/hashes.json."""
|
|
34
|
-
if os.path.exists(HASHES_FILE):
|
|
35
|
-
with open(HASHES_FILE) as f:
|
|
36
|
-
return json.load(f)
|
|
37
|
-
return {}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def save_hashes(hashes):
|
|
41
|
-
"""Write file hashes to .rlsbl/hashes.json."""
|
|
42
|
-
os.makedirs(os.path.dirname(HASHES_FILE), exist_ok=True)
|
|
43
|
-
with open(HASHES_FILE, "w") as f:
|
|
44
|
-
json.dump(hashes, f, indent=2)
|
|
45
|
-
f.write("\n")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
NEXT_STEPS = {
|
|
49
|
-
"npm": [
|
|
50
|
-
"Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)",
|
|
51
|
-
"Push to GitHub to activate the CI workflow",
|
|
52
|
-
"Run rlsbl release [patch|minor|major]",
|
|
53
|
-
],
|
|
54
|
-
"pypi": [
|
|
55
|
-
"Push to GitHub",
|
|
56
|
-
"Configure Trusted Publishing on pypi.org",
|
|
57
|
-
"Run rlsbl release [patch|minor|major]",
|
|
58
|
-
],
|
|
59
|
-
"go": [
|
|
60
|
-
"GoReleaser runs in CI via GitHub Actions (no local install needed)",
|
|
61
|
-
"Push to GitHub to activate the CI workflow",
|
|
62
|
-
"Run rlsbl release [patch|minor|major]",
|
|
63
|
-
],
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def process_template(template_content, vars_dict):
|
|
68
|
-
"""Process a template string by replacing {{varName}} placeholders with values.
|
|
69
|
-
|
|
70
|
-
Returns (content, unreplaced) where unreplaced is a list of unmatched var names.
|
|
71
|
-
"""
|
|
72
|
-
unreplaced = []
|
|
73
|
-
|
|
74
|
-
def replacer(match):
|
|
75
|
-
var_name = match.group(1)
|
|
76
|
-
if var_name in vars_dict:
|
|
77
|
-
return vars_dict[var_name]
|
|
78
|
-
unreplaced.append(var_name)
|
|
79
|
-
return match.group(0)
|
|
80
|
-
|
|
81
|
-
content = re.sub(r"\{\{(\w+)\}\}", replacer, template_content)
|
|
82
|
-
return content, unreplaced
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _save_base(target, content):
|
|
86
|
-
"""Save rendered template content as the merge base for future three-way merges."""
|
|
87
|
-
base_path = os.path.join(BASES_DIR, target)
|
|
88
|
-
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
89
|
-
with open(base_path, "w", encoding="utf-8") as f:
|
|
90
|
-
f.write(content)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _load_base(target):
|
|
94
|
-
"""Load the stored merge base for a target file. Returns None if not stored."""
|
|
95
|
-
base_path = os.path.join(BASES_DIR, target)
|
|
96
|
-
if not os.path.exists(base_path):
|
|
97
|
-
return None
|
|
98
|
-
with open(base_path, "r", encoding="utf-8") as f:
|
|
99
|
-
return f.read()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _three_way_merge(ours_text, base_text, theirs_text):
|
|
103
|
-
"""Three-way merge using git merge-file.
|
|
104
|
-
|
|
105
|
-
Writes three temp files in the project dir (not /tmp), runs
|
|
106
|
-
`git merge-file -p ours base theirs`, and returns (merged_text, has_conflicts).
|
|
107
|
-
Exit code: 0 = clean merge, positive = number of conflicts, negative = error.
|
|
108
|
-
"""
|
|
109
|
-
ours_tmp = theirs_tmp = base_tmp = None
|
|
110
|
-
try:
|
|
111
|
-
ours_tmp = tempfile.NamedTemporaryFile(
|
|
112
|
-
mode="w", suffix=".ours", dir=".", delete=False, encoding="utf-8",
|
|
113
|
-
)
|
|
114
|
-
ours_tmp.write(ours_text)
|
|
115
|
-
ours_tmp.close()
|
|
116
|
-
|
|
117
|
-
base_tmp = tempfile.NamedTemporaryFile(
|
|
118
|
-
mode="w", suffix=".base", dir=".", delete=False, encoding="utf-8",
|
|
119
|
-
)
|
|
120
|
-
base_tmp.write(base_text)
|
|
121
|
-
base_tmp.close()
|
|
122
|
-
|
|
123
|
-
theirs_tmp = tempfile.NamedTemporaryFile(
|
|
124
|
-
mode="w", suffix=".theirs", dir=".", delete=False, encoding="utf-8",
|
|
125
|
-
)
|
|
126
|
-
theirs_tmp.write(theirs_text)
|
|
127
|
-
theirs_tmp.close()
|
|
128
|
-
|
|
129
|
-
result = subprocess.run(
|
|
130
|
-
["git", "merge-file", "-p", ours_tmp.name, base_tmp.name, theirs_tmp.name],
|
|
131
|
-
capture_output=True, text=True,
|
|
132
|
-
)
|
|
133
|
-
merged_text = result.stdout
|
|
134
|
-
# Exit code 0 = clean, positive = number of conflicts, negative = error
|
|
135
|
-
has_conflicts = result.returncode > 0
|
|
136
|
-
if result.returncode < 0:
|
|
137
|
-
# Treat errors as conflicts so the caller knows something went wrong
|
|
138
|
-
has_conflicts = True
|
|
139
|
-
return merged_text, has_conflicts
|
|
140
|
-
finally:
|
|
141
|
-
for tmp in (ours_tmp, base_tmp, theirs_tmp):
|
|
142
|
-
if tmp is not None:
|
|
143
|
-
try:
|
|
144
|
-
os.unlink(tmp.name)
|
|
145
|
-
except OSError:
|
|
146
|
-
pass
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
150
|
-
existing_hashes=None):
|
|
151
|
-
"""Process a list of template mappings: read each template, apply vars, write target files.
|
|
152
|
-
|
|
153
|
-
Uses a universal three-way merge (via git merge-file) for existing files:
|
|
154
|
-
base (last scaffolded version) + ours (user's current file) + theirs (new template).
|
|
155
|
-
USER_OWNED files are never overwritten or merged (except LICENSE year update).
|
|
156
|
-
|
|
157
|
-
Returns (created, skipped, warnings, new_hashes).
|
|
158
|
-
created/skipped are lists of (target, status) tuples for unified display.
|
|
159
|
-
"""
|
|
160
|
-
if existing_hashes is None:
|
|
161
|
-
existing_hashes = {}
|
|
162
|
-
created = []
|
|
163
|
-
skipped = []
|
|
164
|
-
warnings = []
|
|
165
|
-
new_hashes = {}
|
|
166
|
-
|
|
167
|
-
for mapping in mappings:
|
|
168
|
-
template = mapping["template"]
|
|
169
|
-
target = mapping["target"]
|
|
170
|
-
|
|
171
|
-
template_path = os.path.join(template_dir, template)
|
|
172
|
-
if not os.path.exists(template_path):
|
|
173
|
-
warnings.append(f"Template not found: {template_path}")
|
|
174
|
-
continue
|
|
175
|
-
|
|
176
|
-
with open(template_path, "r", encoding="utf-8") as f:
|
|
177
|
-
raw = f.read()
|
|
178
|
-
theirs, unreplaced = process_template(raw, vars_dict)
|
|
179
|
-
|
|
180
|
-
# --- New file or force overwrite: write and save base ---
|
|
181
|
-
if not os.path.exists(target) or force:
|
|
182
|
-
is_overwrite = os.path.exists(target) and force
|
|
183
|
-
target_dir = os.path.dirname(target)
|
|
184
|
-
if target_dir and target_dir != ".":
|
|
185
|
-
os.makedirs(target_dir, exist_ok=True)
|
|
186
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
187
|
-
f.write(theirs)
|
|
188
|
-
_save_base(target, theirs)
|
|
189
|
-
new_hashes[target] = file_hash(target)
|
|
190
|
-
status = "overwritten" if is_overwrite else "created"
|
|
191
|
-
created.append((target, status))
|
|
192
|
-
if unreplaced:
|
|
193
|
-
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
194
|
-
continue
|
|
195
|
-
|
|
196
|
-
# --- Existing file, not forced ---
|
|
197
|
-
|
|
198
|
-
# User-owned files: never touch after initial scaffold,
|
|
199
|
-
# except LICENSE gets its copyright year updated on --update.
|
|
200
|
-
if target in USER_OWNED:
|
|
201
|
-
if update and target == "LICENSE":
|
|
202
|
-
from datetime import datetime
|
|
203
|
-
current_year = str(datetime.now().year)
|
|
204
|
-
with open(target, "r", encoding="utf-8") as f:
|
|
205
|
-
content = f.read()
|
|
206
|
-
# Match "Copyright (c) YYYY" or "Copyright (c) YYYY-YYYY"
|
|
207
|
-
# Capture the original end-year to report the range in the status
|
|
208
|
-
old_year = None
|
|
209
|
-
def _capture_range(m):
|
|
210
|
-
nonlocal old_year
|
|
211
|
-
if m.group(2) == current_year:
|
|
212
|
-
return m.group(0)
|
|
213
|
-
old_year = f"{m.group(1).split()[-1]}-{m.group(2)}"
|
|
214
|
-
return f"{m.group(1)}-{current_year}"
|
|
215
|
-
updated = re.sub(
|
|
216
|
-
r"(Copyright\s+\(c\)\s+\d{4})-(\d{4})",
|
|
217
|
-
_capture_range,
|
|
218
|
-
content,
|
|
219
|
-
)
|
|
220
|
-
if updated == content:
|
|
221
|
-
# No range found or range already current -- try single year
|
|
222
|
-
def _capture_single(m):
|
|
223
|
-
nonlocal old_year
|
|
224
|
-
if m.group(2) == current_year:
|
|
225
|
-
return m.group(0)
|
|
226
|
-
old_year = m.group(2)
|
|
227
|
-
return f"{m.group(1)}{m.group(2)}-{current_year}"
|
|
228
|
-
updated = re.sub(
|
|
229
|
-
r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
|
|
230
|
-
_capture_single,
|
|
231
|
-
content,
|
|
232
|
-
)
|
|
233
|
-
if updated != content:
|
|
234
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
235
|
-
f.write(updated)
|
|
236
|
-
year_detail = (
|
|
237
|
-
f"year updated ({old_year} -> {old_year.split('-')[0]}-{current_year})"
|
|
238
|
-
if old_year and "-" in old_year
|
|
239
|
-
else f"year updated ({old_year} -> {old_year}-{current_year})"
|
|
240
|
-
) if old_year else "year updated"
|
|
241
|
-
created.append(("LICENSE", year_detail))
|
|
242
|
-
else:
|
|
243
|
-
skipped.append((target, "user-owned"))
|
|
244
|
-
else:
|
|
245
|
-
skipped.append((target, "user-owned"))
|
|
246
|
-
continue
|
|
247
|
-
|
|
248
|
-
# --- Three-way merge for all other existing files ---
|
|
249
|
-
with open(target, "r", encoding="utf-8") as f:
|
|
250
|
-
ours = f.read()
|
|
251
|
-
base = _load_base(target)
|
|
252
|
-
|
|
253
|
-
if base is None:
|
|
254
|
-
# No base stored (legacy project or first update after migration).
|
|
255
|
-
# Cannot do a three-way merge. Seed the base for next time.
|
|
256
|
-
_save_base(target, theirs)
|
|
257
|
-
if ours == theirs:
|
|
258
|
-
skipped.append((target, "unchanged, base seeded"))
|
|
259
|
-
else:
|
|
260
|
-
warnings.append(
|
|
261
|
-
f"{target}: no base stored, cannot merge; "
|
|
262
|
-
"run scaffold --force to reset"
|
|
263
|
-
)
|
|
264
|
-
skipped.append((target, "no base -- run scaffold --force to enable merging"))
|
|
265
|
-
continue
|
|
266
|
-
|
|
267
|
-
if ours == base:
|
|
268
|
-
# User did not customize -- clean update: write theirs.
|
|
269
|
-
target_dir = os.path.dirname(target)
|
|
270
|
-
if target_dir and target_dir != ".":
|
|
271
|
-
os.makedirs(target_dir, exist_ok=True)
|
|
272
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
273
|
-
f.write(theirs)
|
|
274
|
-
_save_base(target, theirs)
|
|
275
|
-
new_hashes[target] = file_hash(target)
|
|
276
|
-
created.append((target, "updated"))
|
|
277
|
-
if unreplaced:
|
|
278
|
-
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
279
|
-
elif base == theirs:
|
|
280
|
-
# Template did not change -- nothing to do.
|
|
281
|
-
skipped.append((target, "unchanged"))
|
|
282
|
-
elif ours == theirs:
|
|
283
|
-
# User and template converged to same content -- nothing to do.
|
|
284
|
-
skipped.append((target, "unchanged"))
|
|
285
|
-
else:
|
|
286
|
-
# Both user and template changed -- three-way merge.
|
|
287
|
-
merged, has_conflicts = _three_way_merge(ours, base, theirs)
|
|
288
|
-
target_dir = os.path.dirname(target)
|
|
289
|
-
if target_dir and target_dir != ".":
|
|
290
|
-
os.makedirs(target_dir, exist_ok=True)
|
|
291
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
292
|
-
f.write(merged)
|
|
293
|
-
_save_base(target, theirs)
|
|
294
|
-
new_hashes[target] = file_hash(target)
|
|
295
|
-
if has_conflicts:
|
|
296
|
-
created.append((target, "CONFLICTS -- resolve manually"))
|
|
297
|
-
warnings.append(f"{target}: merge conflicts detected, resolve manually")
|
|
298
|
-
else:
|
|
299
|
-
created.append((target, "merged"))
|
|
300
|
-
if unreplaced:
|
|
301
|
-
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
302
|
-
|
|
303
|
-
return created, skipped, warnings, new_hashes
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnings,
|
|
307
|
-
registry=None, flags=None, registries=None):
|
|
308
|
-
"""Shared post-processing for scaffold: chmod, hooks, version marker, hashes, tagging, summary.
|
|
309
|
-
|
|
310
|
-
all_hash_dicts is a list of dicts to merge into existing_hashes.
|
|
311
|
-
flags is the CLI flags dict (used for tagging check).
|
|
312
|
-
registries is a list of registry names (used for tagging).
|
|
313
|
-
"""
|
|
314
|
-
if flags is None:
|
|
315
|
-
flags = {}
|
|
316
|
-
if registries is None:
|
|
317
|
-
registries = [registry] if registry else []
|
|
318
|
-
# Make all shell scripts in .rlsbl/hooks/ executable
|
|
319
|
-
hooks_dir = os.path.join(".", ".rlsbl", "hooks")
|
|
320
|
-
if os.path.isdir(hooks_dir):
|
|
321
|
-
for entry in os.listdir(hooks_dir):
|
|
322
|
-
if entry.endswith(".sh"):
|
|
323
|
-
os.chmod(os.path.join(hooks_dir, entry), 0o755)
|
|
324
|
-
|
|
325
|
-
# Auto-install pre-push hook as a one-liner that delegates to the subcommand
|
|
326
|
-
hook_target = os.path.join(".git", "hooks", "pre-push")
|
|
327
|
-
if os.path.isdir(".git"):
|
|
328
|
-
if not os.path.exists(hook_target):
|
|
329
|
-
hook_content = "#!/usr/bin/env bash\nexec rlsbl pre-push-check \"$@\"\n"
|
|
330
|
-
os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
|
|
331
|
-
with open(hook_target, "w", encoding="utf-8") as f:
|
|
332
|
-
f.write(hook_content)
|
|
333
|
-
os.chmod(hook_target, 0o755)
|
|
334
|
-
print("Installed pre-push hook (.git/hooks/pre-push)")
|
|
335
|
-
|
|
336
|
-
# Write scaffolding version marker so the pre-push hook can detect drift
|
|
337
|
-
from rlsbl import __version__
|
|
338
|
-
marker_dir = os.path.join(".", ".rlsbl")
|
|
339
|
-
os.makedirs(marker_dir, exist_ok=True)
|
|
340
|
-
marker_path = os.path.join(marker_dir, "version")
|
|
341
|
-
with open(marker_path, "w") as f:
|
|
342
|
-
f.write(__version__ + "\n")
|
|
343
|
-
print("Wrote scaffolding version marker (.rlsbl/version)")
|
|
344
|
-
|
|
345
|
-
# Persist file hashes for future --update customization detection
|
|
346
|
-
all_new_hashes = {}
|
|
347
|
-
for h in all_hash_dicts:
|
|
348
|
-
all_new_hashes.update(h)
|
|
349
|
-
existing_hashes.update(all_new_hashes)
|
|
350
|
-
save_hashes(existing_hashes)
|
|
351
|
-
|
|
352
|
-
# Ecosystem tagging
|
|
353
|
-
if should_tag(flags):
|
|
354
|
-
ensure_tags(registries)
|
|
355
|
-
|
|
356
|
-
# Print unified file list with dot-padded status column
|
|
357
|
-
all_files = [(t, s) for t, s in created] + [(t, s) for t, s in skipped]
|
|
358
|
-
if all_files:
|
|
359
|
-
# Sort by target path for stable output
|
|
360
|
-
all_files.sort(key=lambda item: item[0])
|
|
361
|
-
# Compute padding width: longest target path + minimum 4 dots
|
|
362
|
-
max_target_len = max(len(t) for t, _ in all_files)
|
|
363
|
-
pad_width = max_target_len + 4
|
|
364
|
-
print("Files:")
|
|
365
|
-
for target, status in all_files:
|
|
366
|
-
# Fill gap between target and status with dots
|
|
367
|
-
dots = " " + "." * (pad_width - len(target)) + " "
|
|
368
|
-
print(f" {target}{dots}{status}")
|
|
369
|
-
|
|
370
|
-
if warnings:
|
|
371
|
-
print("Warnings:")
|
|
372
|
-
for w in warnings:
|
|
373
|
-
print(f" {w}")
|
|
374
|
-
|
|
375
|
-
# Helpful note when existing CI workflow is preserved
|
|
376
|
-
ci_path = ".github/workflows/ci.yml"
|
|
377
|
-
if any(t == ci_path for t, _ in skipped):
|
|
378
|
-
print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
|
|
379
|
-
|
|
380
|
-
# Next steps
|
|
381
|
-
if registry:
|
|
382
|
-
steps = NEXT_STEPS.get(registry)
|
|
383
|
-
if steps:
|
|
384
|
-
print("\nNext steps:")
|
|
385
|
-
for i, step in enumerate(steps, 1):
|
|
386
|
-
print(f" {i}. {step}")
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def run_cmd(registry, args, flags):
|
|
390
|
-
"""Init command handler.
|
|
391
|
-
|
|
392
|
-
Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
|
|
393
|
-
from templates.
|
|
394
|
-
"""
|
|
395
|
-
reg = REGISTRIES[registry]
|
|
396
|
-
|
|
397
|
-
# Check that a project file exists
|
|
398
|
-
if not reg.check_project_exists("."):
|
|
399
|
-
print(f"Error: no {registry} project found in current directory.", file=sys.stderr)
|
|
400
|
-
print(reg.get_project_init_hint(), file=sys.stderr)
|
|
401
|
-
sys.exit(1)
|
|
402
|
-
|
|
403
|
-
# Gather template variables
|
|
404
|
-
vars_dict = reg.get_template_vars(".")
|
|
405
|
-
from datetime import datetime
|
|
406
|
-
vars_dict["year"] = str(datetime.now().year)
|
|
407
|
-
|
|
408
|
-
force = flags.get("force", False)
|
|
409
|
-
update = flags.get("update", False)
|
|
410
|
-
|
|
411
|
-
existing_hashes = load_hashes()
|
|
412
|
-
|
|
413
|
-
# Process registry-specific templates
|
|
414
|
-
reg_created, reg_skipped, reg_warnings, reg_hashes = process_mappings(
|
|
415
|
-
reg.get_template_dir(),
|
|
416
|
-
reg.get_template_mappings(),
|
|
417
|
-
vars_dict,
|
|
418
|
-
force,
|
|
419
|
-
update,
|
|
420
|
-
existing_hashes,
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
# Process shared templates (skip if another registry already handled them)
|
|
424
|
-
shared_created, shared_skipped, shared_warnings, shared_hashes = [], [], [], {}
|
|
425
|
-
if not flags.get("skip-shared"):
|
|
426
|
-
shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
|
|
427
|
-
reg.get_shared_template_dir(),
|
|
428
|
-
reg.get_shared_template_mappings(),
|
|
429
|
-
vars_dict,
|
|
430
|
-
force,
|
|
431
|
-
update,
|
|
432
|
-
existing_hashes,
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
created = reg_created + shared_created
|
|
436
|
-
skipped = reg_skipped + shared_skipped
|
|
437
|
-
warnings = reg_warnings + shared_warnings
|
|
438
|
-
|
|
439
|
-
_finalize_scaffold(
|
|
440
|
-
existing_hashes, [reg_hashes, shared_hashes],
|
|
441
|
-
created, skipped, warnings, registry=registry,
|
|
442
|
-
flags=flags, registries=[registry],
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
def run_cmd_multi(registries_list, args, flags):
|
|
447
|
-
"""Scaffold for multiple registries with a merged publish workflow.
|
|
448
|
-
|
|
449
|
-
Uses the primary registry for template vars and CI, then writes a merged
|
|
450
|
-
publish.yml that contains jobs for all detected registries.
|
|
451
|
-
"""
|
|
452
|
-
primary = registries_list[0]
|
|
453
|
-
reg = REGISTRIES[primary]
|
|
454
|
-
|
|
455
|
-
if not reg.check_project_exists("."):
|
|
456
|
-
print(f"Error: no {primary} project found in current directory.", file=sys.stderr)
|
|
457
|
-
sys.exit(1)
|
|
458
|
-
|
|
459
|
-
print(f"Multiple registries detected: {', '.join(registries_list)}")
|
|
460
|
-
print("Scaffolding with merged publish workflow.")
|
|
461
|
-
|
|
462
|
-
vars_dict = reg.get_template_vars(".")
|
|
463
|
-
from datetime import datetime
|
|
464
|
-
vars_dict["year"] = str(datetime.now().year)
|
|
465
|
-
|
|
466
|
-
force = flags.get("force", False)
|
|
467
|
-
update = flags.get("update", False)
|
|
468
|
-
existing_hashes = load_hashes()
|
|
469
|
-
|
|
470
|
-
# Process primary registry CI template only (publish will come from merged)
|
|
471
|
-
ci_mappings = [m for m in reg.get_template_mappings() if "publish" not in m["template"]]
|
|
472
|
-
ci_created, ci_skipped, ci_warnings, ci_hashes = process_mappings(
|
|
473
|
-
reg.get_template_dir(),
|
|
474
|
-
ci_mappings,
|
|
475
|
-
vars_dict,
|
|
476
|
-
force,
|
|
477
|
-
update,
|
|
478
|
-
existing_hashes,
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
# Process merged publish workflow template
|
|
482
|
-
merged_tpl_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
|
483
|
-
"templates", "merged")
|
|
484
|
-
merged_created, merged_skipped, merged_warnings, merged_hashes = process_mappings(
|
|
485
|
-
merged_tpl_dir,
|
|
486
|
-
[{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"}],
|
|
487
|
-
vars_dict,
|
|
488
|
-
force,
|
|
489
|
-
update,
|
|
490
|
-
existing_hashes,
|
|
491
|
-
)
|
|
492
|
-
|
|
493
|
-
# Process shared templates (once)
|
|
494
|
-
shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
|
|
495
|
-
reg.get_shared_template_dir(),
|
|
496
|
-
reg.get_shared_template_mappings(),
|
|
497
|
-
vars_dict,
|
|
498
|
-
force,
|
|
499
|
-
update,
|
|
500
|
-
existing_hashes,
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
created = ci_created + merged_created + shared_created
|
|
504
|
-
skipped = ci_skipped + merged_skipped + shared_skipped
|
|
505
|
-
warnings = ci_warnings + merged_warnings + shared_warnings
|
|
506
|
-
|
|
507
|
-
_finalize_scaffold(
|
|
508
|
-
existing_hashes, [ci_hashes, merged_hashes, shared_hashes],
|
|
509
|
-
created, skipped, warnings,
|
|
510
|
-
flags=flags, registries=registries_list,
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
# Show combined next steps for dual-registry
|
|
514
|
-
print("\nNext steps:")
|
|
515
|
-
print(" 1. Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)")
|
|
516
|
-
print(" 2. Configure Trusted Publishing on pypi.org")
|
|
517
|
-
print(" 3. Push to GitHub to activate the CI workflow")
|
|
518
|
-
print(" 4. Run rlsbl release [patch|minor|major]")
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""Pre-push-check command: verify CHANGELOG.md has an entry for the current version."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
from ..registries import REGISTRIES
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _detect_version():
|
|
11
|
-
"""Detect version using registry adapters.
|
|
12
|
-
|
|
13
|
-
Returns (version_string, registry_name) or (None, None) if undetectable.
|
|
14
|
-
"""
|
|
15
|
-
for name in ("go", "npm", "pypi"):
|
|
16
|
-
reg = REGISTRIES[name]
|
|
17
|
-
if reg.check_project_exists("."):
|
|
18
|
-
return reg.read_version("."), name
|
|
19
|
-
return None, None
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def run_cmd(registry, args, flags):
|
|
23
|
-
"""Check that CHANGELOG.md has an entry for the current project version.
|
|
24
|
-
|
|
25
|
-
Exits 1 if no changelog entry is found; exits 0 silently on success.
|
|
26
|
-
"""
|
|
27
|
-
version, _project_type = _detect_version()
|
|
28
|
-
if not version:
|
|
29
|
-
# Cannot detect version -- nothing to check
|
|
30
|
-
sys.exit(0)
|
|
31
|
-
|
|
32
|
-
if not os.path.exists("CHANGELOG.md"):
|
|
33
|
-
# No changelog file -- nothing to check
|
|
34
|
-
sys.exit(0)
|
|
35
|
-
|
|
36
|
-
with open("CHANGELOG.md", "r", encoding="utf-8") as f:
|
|
37
|
-
content = f.read()
|
|
38
|
-
|
|
39
|
-
# Look for a heading like "## <version>"
|
|
40
|
-
pattern = re.compile(r"^## " + re.escape(version) + r"\s*$", re.MULTILINE)
|
|
41
|
-
if pattern.search(content):
|
|
42
|
-
sys.exit(0)
|
|
43
|
-
|
|
44
|
-
print(f"Error: CHANGELOG.md has no entry for version {version}.", file=sys.stderr)
|
|
45
|
-
print(f"Add a '## {version}' section before pushing.", file=sys.stderr)
|
|
46
|
-
sys.exit(1)
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"""Record-gif command: record a demo GIF using vhs."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import shutil
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
import tempfile
|
|
8
|
-
|
|
9
|
-
from .. import detect_registries
|
|
10
|
-
from ..registries import REGISTRIES
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _get_bin_command():
|
|
14
|
-
"""Auto-detect the project's binary command name via registry template vars."""
|
|
15
|
-
regs = detect_registries()
|
|
16
|
-
if not regs:
|
|
17
|
-
return None
|
|
18
|
-
# Use the first detected registry
|
|
19
|
-
registry_module = REGISTRIES.get(regs[0])
|
|
20
|
-
if not registry_module:
|
|
21
|
-
return None
|
|
22
|
-
try:
|
|
23
|
-
tvars = registry_module.get_template_vars(".")
|
|
24
|
-
return tvars.get("binCommand") or None
|
|
25
|
-
except Exception:
|
|
26
|
-
return None
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def run_cmd(registry, args, flags):
|
|
30
|
-
"""Record a demo GIF of '<binCommand> --help' using vhs.
|
|
31
|
-
|
|
32
|
-
Requires vhs (https://github.com/charmbracelet/vhs) to be installed.
|
|
33
|
-
Output is saved to assets/demo.gif.
|
|
34
|
-
"""
|
|
35
|
-
if not shutil.which("vhs"):
|
|
36
|
-
print("Error: vhs is required.", file=sys.stderr)
|
|
37
|
-
print("Install: go install github.com/charmbracelet/vhs@latest", file=sys.stderr)
|
|
38
|
-
sys.exit(1)
|
|
39
|
-
|
|
40
|
-
bin_command = _get_bin_command()
|
|
41
|
-
if not bin_command:
|
|
42
|
-
print("Error: could not detect project binary command.", file=sys.stderr)
|
|
43
|
-
print("Ensure package.json, pyproject.toml, or go.mod exists with a CLI entry point.", file=sys.stderr)
|
|
44
|
-
sys.exit(1)
|
|
45
|
-
|
|
46
|
-
# Parse configurable VHS parameters from flags
|
|
47
|
-
width = int(flags.get("width", 1200))
|
|
48
|
-
height = int(flags.get("height", 600))
|
|
49
|
-
font_size = int(flags.get("font-size", 24))
|
|
50
|
-
duration = int(flags.get("duration", 10))
|
|
51
|
-
|
|
52
|
-
assets_dir = "assets"
|
|
53
|
-
os.makedirs(assets_dir, exist_ok=True)
|
|
54
|
-
|
|
55
|
-
# Create a temporary VHS tape file in the project directory
|
|
56
|
-
tape_content = (
|
|
57
|
-
'Set FontFamily "monospace"\n'
|
|
58
|
-
f"Set FontSize {font_size}\n"
|
|
59
|
-
f"Set Width {width}\n"
|
|
60
|
-
f"Set Height {height}\n"
|
|
61
|
-
"Set TypingSpeed 50ms\n"
|
|
62
|
-
f'Type "{bin_command} --help"\n'
|
|
63
|
-
"Enter\n"
|
|
64
|
-
f"Sleep {duration}s\n"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
tape_fd, tape_path = tempfile.mkstemp(suffix=".tape", dir=".")
|
|
68
|
-
try:
|
|
69
|
-
with os.fdopen(tape_fd, "w") as f:
|
|
70
|
-
f.write(tape_content)
|
|
71
|
-
|
|
72
|
-
output_path = os.path.join(assets_dir, "demo.gif")
|
|
73
|
-
print("Recording demo...")
|
|
74
|
-
|
|
75
|
-
subprocess.run(
|
|
76
|
-
["vhs", tape_path, "-o", output_path],
|
|
77
|
-
check=True, timeout=120,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
print(f"Done. GIF saved to {output_path}")
|
|
81
|
-
except subprocess.CalledProcessError:
|
|
82
|
-
print("Error: vhs recording failed.", file=sys.stderr)
|
|
83
|
-
sys.exit(1)
|
|
84
|
-
except subprocess.TimeoutExpired:
|
|
85
|
-
print("Error: vhs recording timed out after 120s.", file=sys.stderr)
|
|
86
|
-
sys.exit(1)
|
|
87
|
-
finally:
|
|
88
|
-
# Clean up the temp tape file
|
|
89
|
-
try:
|
|
90
|
-
os.unlink(tape_path)
|
|
91
|
-
except OSError:
|
|
92
|
-
pass
|