rlsbl 0.6.0 → 0.7.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/README.md +1 -1
- package/package.json +1 -1
- package/rlsbl/__init__.py +2 -4
- package/rlsbl/commands/init_cmd.py +193 -29
- package/templates/shared/claude-settings.json.tpl +1 -13
- package/rlsbl/commands/check_prs.py +0 -26
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ When you run `release`, the following happens in order:
|
|
|
145
145
|
| `.rlsbl/hooks/post-release.sh` | Shared | User-customizable post-release actions |
|
|
146
146
|
| `.git/hooks/pre-push` | Shared | One-liner that calls `rlsbl pre-push-check` |
|
|
147
147
|
|
|
148
|
-
Hook files are made executable automatically. The `
|
|
148
|
+
Hook files are made executable automatically. The `record-gif` and `pre-push-check` functionality is provided as built-in subcommands (`rlsbl record-gif`, `rlsbl pre-push-check`) rather than scaffolded scripts.
|
|
149
149
|
|
|
150
150
|
The scaffolded `.gitignore` includes a `*.local-only` pattern. Create a `.local-only/` directory or rename files with a `.local-only` suffix to keep them out of version control -- useful for local-only assets, experiments, and keeping the working tree clean for tools that check `git status`.
|
|
151
151
|
|
package/package.json
CHANGED
package/rlsbl/__init__.py
CHANGED
|
@@ -40,7 +40,7 @@ __version__ = _detect_version()
|
|
|
40
40
|
|
|
41
41
|
REGISTRIES = ("npm", "pypi", "go")
|
|
42
42
|
COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover", "watch",
|
|
43
|
-
"
|
|
43
|
+
"pre-push-check", "record-gif")
|
|
44
44
|
COMMAND_ALIASES = {"init": "scaffold"}
|
|
45
45
|
|
|
46
46
|
HELP = f"""\
|
|
@@ -55,7 +55,6 @@ Usage:
|
|
|
55
55
|
rlsbl undo [--yes] Revert the last release
|
|
56
56
|
rlsbl discover [--mine] List rlsbl ecosystem projects
|
|
57
57
|
rlsbl watch [<commit-sha>] Watch CI runs for a commit
|
|
58
|
-
rlsbl check-prs List open PRs (informational)
|
|
59
58
|
rlsbl pre-push-check Verify CHANGELOG entry for current version
|
|
60
59
|
rlsbl record-gif Record a demo GIF with vhs
|
|
61
60
|
|
|
@@ -124,7 +123,6 @@ def _get_command_module(command):
|
|
|
124
123
|
"undo": "undo",
|
|
125
124
|
"discover": "discover",
|
|
126
125
|
"watch": "watch",
|
|
127
|
-
"check-prs": "check_prs",
|
|
128
126
|
"pre-push-check": "pre_push_check",
|
|
129
127
|
"record-gif": "record_gif",
|
|
130
128
|
}
|
|
@@ -226,7 +224,7 @@ def main():
|
|
|
226
224
|
elif command == "watch":
|
|
227
225
|
# watch: monitors CI runs, no registry needed
|
|
228
226
|
handler.run_cmd(registry, args, flags)
|
|
229
|
-
elif command in ("
|
|
227
|
+
elif command in ("pre-push-check", "record-gif"):
|
|
230
228
|
# Standalone commands, no registry needed
|
|
231
229
|
handler.run_cmd(registry, args, flags)
|
|
232
230
|
else:
|
|
@@ -5,6 +5,9 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
import sys
|
|
8
|
+
from io import StringIO
|
|
9
|
+
|
|
10
|
+
from ruamel.yaml import YAML
|
|
8
11
|
|
|
9
12
|
from ..config import should_tag
|
|
10
13
|
from ..registries import REGISTRIES
|
|
@@ -19,10 +22,19 @@ APPEND_MARKER = "rlsbl"
|
|
|
19
22
|
# Files where missing entries from the template are merged into the existing file
|
|
20
23
|
MERGEABLE = {".gitignore"}
|
|
21
24
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
# JSON files where template keys are deep-merged into existing user content
|
|
26
|
+
JSON_MERGEABLE = {".claude/settings.json"}
|
|
27
|
+
|
|
28
|
+
# YAML workflow files with job-level merge: template jobs overwrite same-key user jobs,
|
|
29
|
+
# user-added jobs are preserved. Top-level keys (name, on, permissions) come from template.
|
|
30
|
+
YAML_MERGEABLE = {".github/workflows/ci.yml", ".github/workflows/publish.yml"}
|
|
31
|
+
|
|
32
|
+
# Files owned by the user after initial scaffold -- never overwrite or merge
|
|
33
|
+
USER_OWNED = {
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
".rlsbl/hooks/pre-release.sh",
|
|
37
|
+
".rlsbl/hooks/post-release.sh",
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
def file_hash(path):
|
|
@@ -84,6 +96,100 @@ def process_template(template_content, vars_dict):
|
|
|
84
96
|
return content, unreplaced
|
|
85
97
|
|
|
86
98
|
|
|
99
|
+
def _deep_merge(base, override):
|
|
100
|
+
"""Deep-merge two JSON-like structures. User values (base) take precedence.
|
|
101
|
+
|
|
102
|
+
- Dicts: recursively merge; keys only in override are added, base keys kept.
|
|
103
|
+
- Lists: union by value; base items first, then new override items (deduplicated).
|
|
104
|
+
- Scalars: base wins.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(base, dict) and isinstance(override, dict):
|
|
107
|
+
merged = {}
|
|
108
|
+
for key in base:
|
|
109
|
+
if key in override:
|
|
110
|
+
merged[key] = _deep_merge(base[key], override[key])
|
|
111
|
+
else:
|
|
112
|
+
merged[key] = base[key]
|
|
113
|
+
for key in override:
|
|
114
|
+
if key not in base:
|
|
115
|
+
merged[key] = override[key]
|
|
116
|
+
return merged
|
|
117
|
+
if isinstance(base, list) and isinstance(override, list):
|
|
118
|
+
# Deduplicate by value: keep base order, append new items from override
|
|
119
|
+
seen = set()
|
|
120
|
+
for item in base:
|
|
121
|
+
# Use JSON serialization for unhashable items (dicts, lists)
|
|
122
|
+
try:
|
|
123
|
+
seen.add(item)
|
|
124
|
+
except TypeError:
|
|
125
|
+
seen.add(json.dumps(item, sort_keys=True))
|
|
126
|
+
result = list(base)
|
|
127
|
+
for item in override:
|
|
128
|
+
if isinstance(item, (dict, list)):
|
|
129
|
+
key = json.dumps(item, sort_keys=True)
|
|
130
|
+
else:
|
|
131
|
+
key = item
|
|
132
|
+
if key not in seen:
|
|
133
|
+
seen.add(key)
|
|
134
|
+
result.append(item)
|
|
135
|
+
return result
|
|
136
|
+
# Scalars: base (user) wins
|
|
137
|
+
return base
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _yaml_merge_workflow(existing_text, template_text):
|
|
141
|
+
"""Merge a YAML workflow at the job level.
|
|
142
|
+
|
|
143
|
+
Template wins for top-level keys (name, on, permissions, etc.).
|
|
144
|
+
For the 'jobs' key: template jobs overwrite same-key user jobs,
|
|
145
|
+
user-added jobs not in the template are preserved at the end.
|
|
146
|
+
|
|
147
|
+
Returns (merged_text, error_msg). error_msg is None on success.
|
|
148
|
+
"""
|
|
149
|
+
yaml = YAML(typ="rt")
|
|
150
|
+
yaml.preserve_quotes = True
|
|
151
|
+
try:
|
|
152
|
+
existing = yaml.load(existing_text)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return None, f"failed to parse existing YAML: {e}"
|
|
155
|
+
try:
|
|
156
|
+
template = yaml.load(template_text)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return None, f"failed to parse template YAML: {e}"
|
|
159
|
+
|
|
160
|
+
if not isinstance(existing, dict) or not isinstance(template, dict):
|
|
161
|
+
return None, "YAML root is not a mapping"
|
|
162
|
+
|
|
163
|
+
# Start with template as the base (template wins for top-level keys)
|
|
164
|
+
merged = type(template)()
|
|
165
|
+
for key in template:
|
|
166
|
+
merged[key] = template[key]
|
|
167
|
+
|
|
168
|
+
# Merge jobs: template jobs first (overwriting user jobs with same key),
|
|
169
|
+
# then append user-only jobs
|
|
170
|
+
template_jobs = template.get("jobs") or {}
|
|
171
|
+
existing_jobs = existing.get("jobs") or {}
|
|
172
|
+
|
|
173
|
+
merged_jobs = type(template_jobs)() if template_jobs else type(existing_jobs)()
|
|
174
|
+
# Template jobs in template order
|
|
175
|
+
for job_key in template_jobs:
|
|
176
|
+
merged_jobs[job_key] = template_jobs[job_key]
|
|
177
|
+
# User-added jobs not in template, preserved at the end
|
|
178
|
+
for job_key in existing_jobs:
|
|
179
|
+
if job_key not in template_jobs:
|
|
180
|
+
merged_jobs[job_key] = existing_jobs[job_key]
|
|
181
|
+
merged["jobs"] = merged_jobs
|
|
182
|
+
|
|
183
|
+
# Preserve any user top-level keys not in the template (rare, but safe)
|
|
184
|
+
for key in existing:
|
|
185
|
+
if key not in merged:
|
|
186
|
+
merged[key] = existing[key]
|
|
187
|
+
|
|
188
|
+
buf = StringIO()
|
|
189
|
+
yaml.dump(merged, buf)
|
|
190
|
+
return buf.getvalue(), None
|
|
191
|
+
|
|
192
|
+
|
|
87
193
|
def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
88
194
|
existing_hashes=None):
|
|
89
195
|
"""Process a list of template mappings: read each template, apply vars, write target files.
|
|
@@ -91,9 +197,8 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
91
197
|
Skips existing files unless force is True, with special handling:
|
|
92
198
|
- APPENDABLE files: append template sections if the marker is not already present
|
|
93
199
|
- MERGEABLE files: merge missing entries from the template into the existing file
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
200
|
+
- JSON_MERGEABLE files: deep-merge template JSON into existing (user values preserved)
|
|
201
|
+
- YAML_MERGEABLE files: job-level merge for CI workflows (template jobs win, user jobs preserved)
|
|
97
202
|
Returns (created, skipped, warnings, new_hashes).
|
|
98
203
|
"""
|
|
99
204
|
if existing_hashes is None:
|
|
@@ -116,29 +221,88 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
116
221
|
if os.path.exists(target) and not force:
|
|
117
222
|
basename = os.path.basename(target)
|
|
118
223
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
with open(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
224
|
+
# User-owned files are never touched after initial scaffold,
|
|
225
|
+
# except LICENSE gets its copyright year updated on --update.
|
|
226
|
+
if target in USER_OWNED:
|
|
227
|
+
if update and target == "LICENSE":
|
|
228
|
+
from datetime import datetime
|
|
229
|
+
current_year = str(datetime.now().year)
|
|
230
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
231
|
+
content = f.read()
|
|
232
|
+
# Match "Copyright (c) YYYY" or "Copyright (c) YYYY-YYYY"
|
|
233
|
+
updated = re.sub(
|
|
234
|
+
r"(Copyright\s+\(c\)\s+\d{4})-(\d{4})",
|
|
235
|
+
lambda m: (
|
|
236
|
+
m.group(0) if m.group(2) == current_year
|
|
237
|
+
else f"{m.group(1)}-{current_year}"
|
|
238
|
+
),
|
|
239
|
+
content,
|
|
240
|
+
)
|
|
241
|
+
if updated == content:
|
|
242
|
+
# No range found or range already current -- try single year
|
|
243
|
+
updated = re.sub(
|
|
244
|
+
r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
|
|
245
|
+
lambda m: (
|
|
246
|
+
m.group(0) if m.group(2) == current_year
|
|
247
|
+
else f"{m.group(1)}{m.group(2)}-{current_year}"
|
|
248
|
+
),
|
|
249
|
+
content,
|
|
250
|
+
)
|
|
251
|
+
if updated != content:
|
|
252
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
253
|
+
f.write(updated)
|
|
254
|
+
created.append("LICENSE (year updated)")
|
|
255
|
+
else:
|
|
256
|
+
skipped.append(target)
|
|
137
257
|
else:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
258
|
+
skipped.append(target)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
if target in JSON_MERGEABLE:
|
|
262
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
263
|
+
existing_text = f.read()
|
|
264
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
265
|
+
raw = f.read()
|
|
266
|
+
content, unreplaced = process_template(raw, vars_dict)
|
|
267
|
+
try:
|
|
268
|
+
existing_data = json.loads(existing_text)
|
|
269
|
+
template_data = json.loads(content)
|
|
270
|
+
except json.JSONDecodeError as e:
|
|
271
|
+
warnings.append(f"{target}: JSON parse error during merge: {e}")
|
|
272
|
+
skipped.append(target)
|
|
273
|
+
continue
|
|
274
|
+
merged = _deep_merge(existing_data, template_data)
|
|
275
|
+
target_dir = os.path.dirname(target)
|
|
276
|
+
if target_dir and target_dir != ".":
|
|
277
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
278
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
279
|
+
f.write(json.dumps(merged, indent=2))
|
|
280
|
+
f.write("\n")
|
|
281
|
+
created.append(target + " (merged)")
|
|
282
|
+
if unreplaced:
|
|
283
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if target in YAML_MERGEABLE:
|
|
287
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
288
|
+
existing_text = f.read()
|
|
289
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
290
|
+
raw = f.read()
|
|
291
|
+
content, unreplaced = process_template(raw, vars_dict)
|
|
292
|
+
merged_text, err = _yaml_merge_workflow(existing_text, content)
|
|
293
|
+
if err:
|
|
294
|
+
warnings.append(f"{target}: YAML merge skipped: {err}")
|
|
295
|
+
skipped.append(target)
|
|
296
|
+
continue
|
|
297
|
+
target_dir = os.path.dirname(target)
|
|
298
|
+
if target_dir and target_dir != ".":
|
|
299
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
300
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
301
|
+
f.write(merged_text)
|
|
302
|
+
new_hashes[target] = file_hash(target)
|
|
303
|
+
created.append(target + " (merged)")
|
|
304
|
+
if unreplaced:
|
|
305
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
142
306
|
continue
|
|
143
307
|
|
|
144
308
|
if basename in APPENDABLE:
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
"""Check-prs command: list open PRs for awareness."""
|
|
2
|
-
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def run_cmd(registry, args, flags):
|
|
9
|
-
"""List open pull requests in the current repository.
|
|
10
|
-
|
|
11
|
-
Exits silently if gh CLI is not available. Always exits 0 (informational only).
|
|
12
|
-
"""
|
|
13
|
-
if not shutil.which("gh"):
|
|
14
|
-
sys.exit(0)
|
|
15
|
-
|
|
16
|
-
try:
|
|
17
|
-
result = subprocess.run(
|
|
18
|
-
["gh", "pr", "list", "--state", "open", "--limit", "20"],
|
|
19
|
-
capture_output=True, text=True, timeout=15,
|
|
20
|
-
)
|
|
21
|
-
if result.returncode == 0 and result.stdout.strip():
|
|
22
|
-
print(result.stdout.strip())
|
|
23
|
-
except Exception:
|
|
24
|
-
pass
|
|
25
|
-
|
|
26
|
-
sys.exit(0)
|