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 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 `check-prs`, `record-gif`, and `pre-push-check` functionality is provided as built-in subcommands (`rlsbl check-prs`, `rlsbl record-gif`, `rlsbl pre-push-check`) rather than scaffolded scripts.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
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
- "check-prs", "pre-push-check", "record-gif")
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 ("check-prs", "pre-push-check", "record-gif"):
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
- # 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",
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
- - UPDATABLE files (with --update): overwrite only if the file hasn't been customized
95
- (detected via SHA-256 hash comparison against stored hashes)
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
- # In --update mode, overwrite managed files only if not customized
120
- if update and target in UPDATABLE:
121
- current_hash = file_hash(target)
122
- stored_hash = existing_hashes.get(target)
123
- if stored_hash and current_hash == stored_hash:
124
- # File matches stored hash -- not customized, safe to overwrite
125
- with open(template_path, "r", encoding="utf-8") as f:
126
- raw = f.read()
127
- content, unreplaced = process_template(raw, vars_dict)
128
- target_dir = os.path.dirname(target)
129
- if target_dir and target_dir != ".":
130
- os.makedirs(target_dir, exist_ok=True)
131
- with open(target, "w", encoding="utf-8") as f:
132
- f.write(content)
133
- new_hashes[target] = file_hash(target)
134
- created.append(target + " (updated)")
135
- if unreplaced:
136
- warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
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
- # File was customized or no stored hash -- skip conservatively
139
- # Seed the hash so future --update can detect changes
140
- new_hashes[target] = current_hash
141
- skipped.append(f"{target} (customized, use --force to overwrite)")
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,15 +1,3 @@
1
1
  {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "rlsbl check-prs"
10
- }
11
- ]
12
- }
13
- ]
14
- }
2
+ "hooks": {}
15
3
  }
@@ -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)