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.
@@ -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'}")
@@ -0,0 +1,5 @@
1
+ """Registry lookup for rlsbl."""
2
+
3
+ from . import npm, pypi
4
+
5
+ REGISTRIES = {"npm": npm, "pypi": pypi}