rlsbl 0.7.0 → 0.8.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -47,10 +47,12 @@ def _parse_next_link(headers):
47
47
  return None
48
48
  for part in link.split(","):
49
49
  if 'rel="next"' in part:
50
- # Extract URL between < and >
51
50
  start = part.index("<") + 1
52
51
  end = part.index(">")
53
- return part[start:end]
52
+ url = part[start:end]
53
+ if not url.startswith("https://api.github.com/"):
54
+ return None
55
+ return url
54
56
  return None
55
57
 
56
58
 
@@ -4,30 +4,16 @@ import hashlib
4
4
  import json
5
5
  import os
6
6
  import re
7
+ import subprocess
7
8
  import sys
8
- from io import StringIO
9
-
10
- from ruamel.yaml import YAML
9
+ import tempfile
11
10
 
12
11
  from ..config import should_tag
13
12
  from ..registries import REGISTRIES
14
13
  from ..tagging import ensure_tags
15
14
 
16
15
  HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
17
-
18
- # Files where existing content is preserved and template sections are appended
19
- APPENDABLE = {"CLAUDE.md"}
20
- APPEND_MARKER = "rlsbl"
21
-
22
- # Files where missing entries from the template are merged into the existing file
23
- MERGEABLE = {".gitignore"}
24
-
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"}
16
+ BASES_DIR = os.path.join(".rlsbl", "bases")
31
17
 
32
18
  # Files owned by the user after initial scaffold -- never overwrite or merge
33
19
  USER_OWNED = {
@@ -96,110 +82,80 @@ def process_template(template_content, vars_dict):
96
82
  return content, unreplaced
97
83
 
98
84
 
99
- def _deep_merge(base, override):
100
- """Deep-merge two JSON-like structures. User values (base) take precedence.
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)
101
91
 
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
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()
139
100
 
140
- def _yaml_merge_workflow(existing_text, template_text):
141
- """Merge a YAML workflow at the job level.
142
101
 
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.
102
+ def _three_way_merge(ours_text, base_text, theirs_text):
103
+ """Three-way merge using git merge-file.
146
104
 
147
- Returns (merged_text, error_msg). error_msg is None on success.
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.
148
108
  """
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}"
109
+ ours_tmp = theirs_tmp = base_tmp = None
155
110
  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
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
191
147
 
192
148
 
193
149
  def process_mappings(template_dir, mappings, vars_dict, force, update=False,
194
150
  existing_hashes=None):
195
151
  """Process a list of template mappings: read each template, apply vars, write target files.
196
152
 
197
- Skips existing files unless force is True, with special handling:
198
- - APPENDABLE files: append template sections if the marker is not already present
199
- - MERGEABLE files: merge missing entries from the template into the existing file
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)
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
+
202
157
  Returns (created, skipped, warnings, new_hashes).
158
+ created/skipped are lists of (target, status) tuples for unified display.
203
159
  """
204
160
  if existing_hashes is None:
205
161
  existing_hashes = {}
@@ -217,166 +173,132 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
217
173
  warnings.append(f"Template not found: {template_path}")
218
174
  continue
219
175
 
220
- # When file exists and force is not set, use context-aware handling
221
- if os.path.exists(target) and not force:
222
- basename = os.path.basename(target)
223
-
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"
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}"
233
228
  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
- ),
229
+ r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
230
+ _capture_single,
239
231
  content,
240
232
  )
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)
257
- else:
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)}")
306
- continue
307
-
308
- if basename in APPENDABLE:
309
- with open(target, "r", encoding="utf-8") as f:
310
- existing = f.read()
311
- if APPEND_MARKER in existing:
312
- skipped.append(target + " (already has rlsbl section)")
313
- continue
314
- # Append only the ## sections, stripping the top-level # heading
315
- with open(template_path, "r", encoding="utf-8") as f:
316
- raw = f.read()
317
- content, _ = process_template(raw, vars_dict)
318
- lines = content.split("\n")
319
- first_section_idx = None
320
- for i, line in enumerate(lines):
321
- if i > 0 and line.startswith("## "):
322
- first_section_idx = i
323
- break
324
- section = "\n".join(lines[first_section_idx:]) if first_section_idx is not None else content
325
- with open(target, "a", encoding="utf-8") as f:
326
- f.write("\n\n" + section.strip() + "\n")
327
- created.append(target + " (appended)")
328
- continue
329
-
330
- if basename in MERGEABLE:
331
- with open(target, "r", encoding="utf-8") as f:
332
- existing = f.read()
333
- with open(template_path, "r", encoding="utf-8") as f:
334
- raw = f.read()
335
- content, _ = process_template(raw, vars_dict)
336
- existing_lines = {
337
- line.strip() for line in existing.split("\n") if line.strip()
338
- }
339
- # Normalize by stripping trailing slashes so e.g.
340
- # "*.egg-info/" matches "*.egg-info" and vice versa.
341
- existing_normalized = {
342
- line.rstrip("/") for line in existing_lines
343
- }
344
- new_lines = [
345
- line.strip() for line in content.split("\n") if line.strip()
346
- ]
347
- # Only merge non-comment entries that are missing from the existing file
348
- missing = [
349
- line for line in new_lines
350
- if line.rstrip("/") not in existing_normalized
351
- and not line.startswith("#")
352
- ]
353
- if missing:
354
- with open(target, "a", encoding="utf-8") as f:
355
- f.write("\n# Added by rlsbl\n" + "\n".join(missing) + "\n")
356
- created.append(f"{target} (merged {len(missing)} entries)")
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))
357
242
  else:
358
- skipped.append(target + " (all entries present)")
359
- continue
360
-
361
- skipped.append(target)
243
+ skipped.append((target, "user-owned"))
244
+ else:
245
+ skipped.append((target, "user-owned"))
362
246
  continue
363
247
 
364
- with open(template_path, "r", encoding="utf-8") as f:
365
- raw = f.read()
366
- content, unreplaced = process_template(raw, vars_dict)
367
-
368
- # Ensure parent directory exists
369
- target_dir = os.path.dirname(target)
370
- if target_dir and target_dir != ".":
371
- os.makedirs(target_dir, exist_ok=True)
372
-
373
- with open(target, "w", encoding="utf-8") as f:
374
- f.write(content)
375
- new_hashes[target] = file_hash(target)
376
- created.append(target)
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
377
266
 
378
- if unreplaced:
379
- warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
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)}")
380
302
 
381
303
  return created, skipped, warnings, new_hashes
382
304
 
@@ -431,16 +353,19 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
431
353
  if should_tag(flags):
432
354
  ensure_tags(registries)
433
355
 
434
- # Print summary
435
- if created:
436
- print("Created:")
437
- for f in created:
438
- print(f" {f}")
439
-
440
- if skipped:
441
- print("Skipped (already exist, use --update to refresh managed files or --force to overwrite all):")
442
- for f in skipped:
443
- print(f" {f}")
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}")
444
369
 
445
370
  if warnings:
446
371
  print("Warnings:")
@@ -449,7 +374,7 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
449
374
 
450
375
  # Helpful note when existing CI workflow is preserved
451
376
  ci_path = ".github/workflows/ci.yml"
452
- if any(s.startswith(ci_path) for s in skipped):
377
+ if any(t == ci_path for t, _ in skipped):
453
378
  print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
454
379
 
455
380
  # Next steps
@@ -555,7 +480,7 @@ def run_cmd_multi(registries_list, args, flags):
555
480
 
556
481
  # Process merged publish workflow template
557
482
  merged_tpl_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
558
- "..", "templates", "merged")
483
+ "templates", "merged")
559
484
  merged_created, merged_skipped, merged_warnings, merged_hashes = process_mappings(
560
485
  merged_tpl_dir,
561
486
  [{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"}],
@@ -210,6 +210,18 @@ def run_cmd(registry, args, flags):
210
210
  except Exception:
211
211
  pass
212
212
 
213
+ # Update .rlsbl/version marker so it's included in the release commit
214
+ rlsbl_version_marker = os.path.join(".rlsbl", "version")
215
+ if os.path.exists(os.path.dirname(rlsbl_version_marker)):
216
+ try:
217
+ from .. import __version__ as rlsbl_ver
218
+ with open(rlsbl_version_marker, "w") as f:
219
+ f.write(rlsbl_ver + "\n")
220
+ if rlsbl_version_marker not in files_to_commit:
221
+ files_to_commit.append(rlsbl_version_marker)
222
+ except Exception:
223
+ pass
224
+
213
225
  # Commit if anything was actually modified (version bump or tagging)
214
226
  needs_commit = new_version != current_version or not is_clean_tree()
215
227
  if files_to_commit and needs_commit:
@@ -2,12 +2,20 @@
2
2
 
3
3
  import sys
4
4
 
5
- from ..utils import run, check_gh_installed, check_gh_auth, get_push_timeout
5
+ from ..utils import run, check_gh_installed, check_gh_auth, get_push_timeout, is_clean_tree
6
6
 
7
7
 
8
8
  def run_cmd(registry, args, flags):
9
- check_gh_installed()
10
- check_gh_auth()
9
+ if not check_gh_installed():
10
+ print("Error: gh CLI is not installed.", file=sys.stderr)
11
+ sys.exit(1)
12
+ if not check_gh_auth():
13
+ print("Error: gh CLI is not authenticated.", file=sys.stderr)
14
+ sys.exit(1)
15
+
16
+ if not is_clean_tree():
17
+ print("Error: working tree is not clean. Commit your changes first.", file=sys.stderr)
18
+ sys.exit(1)
11
19
 
12
20
  # Find the latest tag
13
21
  try:
@@ -39,12 +39,12 @@ def get_version_file():
39
39
 
40
40
  def get_template_dir():
41
41
  """Returns path to the go-specific template directory."""
42
- return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "go")
42
+ return os.path.join(os.path.dirname(__file__), "..", "templates", "go")
43
43
 
44
44
 
45
45
  def get_shared_template_dir():
46
46
  """Returns path to the shared template directory."""
47
- return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "shared")
47
+ return os.path.join(os.path.dirname(__file__), "..", "templates", "shared")
48
48
 
49
49
 
50
50
  def get_template_vars(dir_path):
@@ -32,7 +32,7 @@ def write_version(dir_path, version):
32
32
 
33
33
  # Preserve trailing newline if present
34
34
  trailing_newline = "\n" if raw.endswith("\n") else ""
35
- output = json.dumps(pkg, indent=indent) + trailing_newline
35
+ output = json.dumps(pkg, indent=indent, ensure_ascii=False) + trailing_newline
36
36
  # Atomic write: write to temp file, then rename
37
37
  tmp_path = pkg_path + ".tmp"
38
38
  with open(tmp_path, "w", encoding="utf-8") as f:
@@ -47,12 +47,12 @@ def get_version_file():
47
47
 
48
48
  def get_template_dir():
49
49
  """Returns path to the npm-specific template directory."""
50
- return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "npm")
50
+ return os.path.join(os.path.dirname(__file__), "..", "templates", "npm")
51
51
 
52
52
 
53
53
  def get_shared_template_dir():
54
54
  """Returns path to the shared template directory."""
55
- return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "shared")
55
+ return os.path.join(os.path.dirname(__file__), "..", "templates", "shared")
56
56
 
57
57
 
58
58
  def get_template_vars(dir_path):
@@ -67,12 +67,12 @@ def get_version_file():
67
67
 
68
68
  def get_template_dir():
69
69
  """Returns path to the pypi-specific template directory."""
70
- return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "pypi")
70
+ return os.path.join(os.path.dirname(__file__), "..", "templates", "pypi")
71
71
 
72
72
 
73
73
  def get_shared_template_dir():
74
74
  """Returns path to the shared template directory."""
75
- return os.path.join(os.path.dirname(__file__), "..", "..", "templates", "shared")
75
+ return os.path.join(os.path.dirname(__file__), "..", "templates", "shared")
76
76
 
77
77
 
78
78
  def get_template_vars(dir_path):
package/rlsbl/tagging.py CHANGED
@@ -32,7 +32,7 @@ def ensure_npm_keyword(dir_path=".", quiet=False):
32
32
 
33
33
  # Preserve trailing newline if present
34
34
  trailing_newline = "\n" if raw.endswith("\n") else ""
35
- output = json.dumps(pkg, indent=indent) + trailing_newline
35
+ output = json.dumps(pkg, indent=indent, ensure_ascii=False) + trailing_newline
36
36
 
37
37
  # Atomic write: write to temp file, then rename
38
38
  tmp_path = pkg_path + ".tmp"
@@ -82,11 +82,16 @@ def ensure_pypi_keyword(dir_path=".", quiet=False):
82
82
  # Find the indent used for existing items
83
83
  item_indent_match = re.search(r'\n( +)"', array_content)
84
84
  item_indent = item_indent_match.group(1) if item_indent_match else " "
85
- new_array_content = array_content.rstrip() + f',\n{item_indent}"rlsbl"\n'
85
+ # Strip trailing comma to avoid double comma when the list
86
+ # already has a trailing comma before the closing bracket
87
+ stripped = array_content.rstrip()
88
+ stripped = stripped.rstrip(",")
89
+ new_array_content = stripped + f',\n{item_indent}"rlsbl"\n'
86
90
  else:
87
91
  # Single-line
88
92
  if array_content.strip():
89
- new_array_content = array_content.rstrip() + ', "rlsbl"'
93
+ stripped_sl = array_content.rstrip().rstrip(",")
94
+ new_array_content = stripped_sl + ', "rlsbl"'
90
95
  else:
91
96
  new_array_content = '"rlsbl"'
92
97
  new_field = prefix + new_array_content + "]"
@@ -8,15 +8,19 @@ set -euo pipefail
8
8
  echo "Running pre-release checks..."
9
9
 
10
10
  if [ -f go.mod ]; then
11
- echo "Detected Go project"
11
+ echo " Go: vet + build + test"
12
12
  go vet ./...
13
13
  go build ./...
14
14
  go test ./... -race -short -count=1
15
- elif [ -f package.json ]; then
16
- echo "Detected npm project"
15
+ fi
16
+
17
+ if [ -f package.json ] && node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)" 2>/dev/null; then
18
+ echo " npm: test"
17
19
  npm test
18
- elif [ -f pyproject.toml ]; then
19
- echo "Detected Python project"
20
+ fi
21
+
22
+ if [ -f pyproject.toml ]; then
23
+ echo " Python: pytest"
20
24
  if command -v uv &>/dev/null; then
21
25
  uv run pytest
22
26
  elif command -v pytest &>/dev/null; then
File without changes
File without changes
File without changes
File without changes