rlsbl 0.5.2 → 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
@@ -62,7 +62,7 @@ rlsbl release major --dry-run --registry npm
62
62
 
63
63
  The version is synced across all detected project files (`package.json`, `pyproject.toml`, `VERSION`) regardless of which registry is primary. Go projects use a plain `VERSION` file as the version source.
64
64
 
65
- If `scripts/pre-release.sh` exists, it runs before any changes are made. A non-zero exit aborts the release. If `scripts/post-release.sh` exists, it runs after the release completes (non-fatal). See [Release flow](#release-flow) for details.
65
+ If `.rlsbl/hooks/pre-release.sh` exists, it runs before any changes are made. A non-zero exit aborts the release. If `.rlsbl/hooks/post-release.sh` exists, it runs after the release completes (non-fatal). See [Release flow](#release-flow) for details.
66
66
 
67
67
  ### status
68
68
 
@@ -120,14 +120,14 @@ When you run `release`, the following happens in order:
120
120
  3. Reads the current version from the primary project file
121
121
  4. Computes the new version and confirms the git tag does not already exist
122
122
  5. Validates that `CHANGELOG.md` contains a `## <new-version>` section
123
- 6. Runs `scripts/pre-release.sh` if present (non-zero exit aborts)
123
+ 6. Runs `.rlsbl/hooks/pre-release.sh` if present (non-zero exit aborts)
124
124
  7. Writes the new version to the primary project file
125
125
  8. Syncs the new version to all other detected project files
126
126
  9. Commits the version bump (uses `safegit` if available, otherwise `git`)
127
127
  10. Pushes the branch to `origin`
128
128
  11. Creates a GitHub Release tagged `v<new-version>` with the changelog entry as notes
129
129
  12. The GitHub Release triggers `publish.yml`, which publishes to the registry
130
- 13. Runs `scripts/post-release.sh` if present (non-fatal -- the release is already complete). The `RLSBL_VERSION` env var is set to the released version. Useful for local install (`go install ./cmd/myapp/`), deploy, or notifications.
130
+ 13. Runs `.rlsbl/hooks/post-release.sh` if present (non-fatal -- the release is already complete). The `RLSBL_VERSION` env var is set to the released version. Useful for local install (`go install ./cmd/myapp/`), deploy, or notifications.
131
131
  14. Spawns a background process that watches CI via `gh run watch`. When CI finishes, it prints the result to stderr (so AI agents can read it) and sends a desktop notification (`notify-send` on Linux, `osascript` on macOS). On CI failure, it also prints the GitHub Actions run URL. This happens automatically -- no configuration needed.
132
132
 
133
133
  ## What scaffold creates
@@ -141,19 +141,17 @@ When you run `release`, the following happens in order:
141
141
  | `.gitignore` | Shared | Standard ignores for the ecosystem |
142
142
  | `CLAUDE.md` | Shared | AI assistant instructions |
143
143
  | `.claude/settings.json` | Shared | Claude Code settings |
144
- | `scripts/check-prs.sh` | Shared | PR review helper |
145
- | `scripts/pre-release.sh` | Shared | Pre-release hook (runs before each release) |
146
- | `scripts/post-release.sh` | Shared | Post-release hook (runs after each release, non-fatal) |
147
- | `scripts/record-gif.sh` | Shared | Terminal recording helper |
148
- | `scripts/pre-push-hook.sh` | Shared | Pre-push changelog enforcement |
144
+ | `.rlsbl/hooks/pre-release.sh` | Shared | User-customizable pre-release validation |
145
+ | `.rlsbl/hooks/post-release.sh` | Shared | User-customizable post-release actions |
146
+ | `.git/hooks/pre-push` | Shared | One-liner that calls `rlsbl pre-push-check` |
149
147
 
150
- All `.sh` files in `scripts/` are made executable automatically. The pre-push hook is installed into `.git/hooks/pre-push` during scaffold.
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.
151
149
 
152
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`.
153
151
 
154
152
  ## Pre-push hook
155
153
 
156
- The scaffolded `scripts/pre-push-hook.sh` is installed as a git pre-push hook during `scaffold`. It prevents pushing when `CHANGELOG.md` lacks an entry for the current version.
154
+ The `rlsbl pre-push-check` subcommand enforces changelog coverage. During `scaffold`, a one-liner git hook is installed at `.git/hooks/pre-push` that calls this subcommand. It prevents pushing when `CHANGELOG.md` lacks an entry for the current version.
157
155
 
158
156
  How it works:
159
157
 
@@ -165,7 +163,7 @@ How it works:
165
163
  To reinstall manually:
166
164
 
167
165
  ```
168
- cp scripts/pre-push-hook.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push
166
+ echo '#!/bin/sh' > .git/hooks/pre-push && echo 'exec rlsbl pre-push-check "$@"' >> .git/hooks/pre-push && chmod +x .git/hooks/pre-push
169
167
  ```
170
168
 
171
169
  ## First publish
@@ -185,7 +183,7 @@ After configuration, all subsequent releases are handled by CI when `rlsbl relea
185
183
  | Variable | Default | Description |
186
184
  |----------|---------|-------------|
187
185
  | `RLSBL_PUSH_TIMEOUT` | `120` | Timeout in seconds for `git push` operations. Increase if your pre-push hooks (e.g. test suites) take longer than 2 minutes. |
188
- | `RLSBL_VERSION` | -- | Set automatically when running `scripts/post-release.sh`. Contains the just-released version string. |
186
+ | `RLSBL_VERSION` | -- | Set automatically when running `.rlsbl/hooks/post-release.sh`. Contains the just-released version string. |
189
187
 
190
188
  ## Requirements
191
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.5.2",
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
@@ -39,7 +39,8 @@ def _detect_version():
39
39
  __version__ = _detect_version()
40
40
 
41
41
  REGISTRIES = ("npm", "pypi", "go")
42
- COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover")
42
+ COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover", "watch",
43
+ "pre-push-check", "record-gif")
43
44
  COMMAND_ALIASES = {"init": "scaffold"}
44
45
 
45
46
  HELP = f"""\
@@ -53,6 +54,9 @@ Usage:
53
54
  rlsbl config Show project configuration
54
55
  rlsbl undo [--yes] Revert the last release
55
56
  rlsbl discover [--mine] List rlsbl ecosystem projects
57
+ rlsbl watch [<commit-sha>] Watch CI runs for a commit
58
+ rlsbl pre-push-check Verify CHANGELOG entry for current version
59
+ rlsbl record-gif Record a demo GIF with vhs
56
60
 
57
61
  Options:
58
62
  --registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
@@ -118,6 +122,9 @@ def _get_command_module(command):
118
122
  "config": "config",
119
123
  "undo": "undo",
120
124
  "discover": "discover",
125
+ "watch": "watch",
126
+ "pre-push-check": "pre_push_check",
127
+ "record-gif": "record_gif",
121
128
  }
122
129
  module_name = module_map.get(command)
123
130
  if not module_name:
@@ -214,6 +221,12 @@ def main():
214
221
  elif command == "discover":
215
222
  # discover: global query, no registry needed
216
223
  handler.run_cmd(registry, args, flags)
224
+ elif command == "watch":
225
+ # watch: monitors CI runs, no registry needed
226
+ handler.run_cmd(registry, args, flags)
227
+ elif command in ("pre-push-check", "record-gif"):
228
+ # Standalone commands, no registry needed
229
+ handler.run_cmd(registry, args, flags)
217
230
  else:
218
231
  # release, status: use explicit registry or auto-detect primary
219
232
  if not registry:
@@ -40,9 +40,9 @@ def run_cmd(registry, args, flags):
40
40
  print(f" {wf}: {'yes' if os.path.exists(path) else 'no'}")
41
41
 
42
42
  print("\nHooks:")
43
- pre_release = os.path.join("scripts", "pre-release.sh")
43
+ pre_release = os.path.join(".rlsbl", "hooks", "pre-release.sh")
44
44
  print(f" pre-release.sh: {'yes' if os.path.exists(pre_release) else 'no'}")
45
- post_release = os.path.join("scripts", "post-release.sh")
45
+ post_release = os.path.join(".rlsbl", "hooks", "post-release.sh")
46
46
  print(f" post-release.sh: {'yes' if os.path.exists(post_release) else 'no'}")
47
47
  pre_push = os.path.join(".git", "hooks", "pre-push")
48
48
  print(f" pre-push hook: {'installed' if os.path.exists(pre_push) else 'not installed'}")
@@ -4,9 +4,10 @@ import hashlib
4
4
  import json
5
5
  import os
6
6
  import re
7
- import shutil
8
- import stat
9
7
  import sys
8
+ from io import StringIO
9
+
10
+ from ruamel.yaml import YAML
10
11
 
11
12
  from ..config import should_tag
12
13
  from ..registries import REGISTRIES
@@ -21,13 +22,19 @@ APPEND_MARKER = "rlsbl"
21
22
  # Files where missing entries from the template are merged into the existing file
22
23
  MERGEABLE = {".gitignore"}
23
24
 
24
- # Files that are safe to overwrite during --update (managed files users typically don't customize)
25
- UPDATABLE = {
26
- ".github/workflows/ci.yml",
27
- ".github/workflows/publish.yml",
28
- "scripts/check-prs.sh",
29
- "scripts/post-release.sh",
30
- "scripts/pre-push-hook.sh",
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",
31
38
  }
32
39
 
33
40
  def file_hash(path):
@@ -89,6 +96,100 @@ def process_template(template_content, vars_dict):
89
96
  return content, unreplaced
90
97
 
91
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
+
92
193
  def process_mappings(template_dir, mappings, vars_dict, force, update=False,
93
194
  existing_hashes=None):
94
195
  """Process a list of template mappings: read each template, apply vars, write target files.
@@ -96,9 +197,8 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
96
197
  Skips existing files unless force is True, with special handling:
97
198
  - APPENDABLE files: append template sections if the marker is not already present
98
199
  - MERGEABLE files: merge missing entries from the template into the existing file
99
- - UPDATABLE files (with --update): overwrite only if the file hasn't been customized
100
- (detected via SHA-256 hash comparison against stored hashes)
101
-
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)
102
202
  Returns (created, skipped, warnings, new_hashes).
103
203
  """
104
204
  if existing_hashes is None:
@@ -121,29 +221,88 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
121
221
  if os.path.exists(target) and not force:
122
222
  basename = os.path.basename(target)
123
223
 
124
- # In --update mode, overwrite managed files only if not customized
125
- if update and target in UPDATABLE:
126
- current_hash = file_hash(target)
127
- stored_hash = existing_hashes.get(target)
128
- if stored_hash and current_hash == stored_hash:
129
- # File matches stored hash -- not customized, safe to overwrite
130
- with open(template_path, "r", encoding="utf-8") as f:
131
- raw = f.read()
132
- content, unreplaced = process_template(raw, vars_dict)
133
- target_dir = os.path.dirname(target)
134
- if target_dir and target_dir != ".":
135
- os.makedirs(target_dir, exist_ok=True)
136
- with open(target, "w", encoding="utf-8") as f:
137
- f.write(content)
138
- new_hashes[target] = file_hash(target)
139
- created.append(target + " (updated)")
140
- if unreplaced:
141
- 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)
142
257
  else:
143
- # File was customized or no stored hash -- skip conservatively
144
- # Seed the hash so future --update can detect changes
145
- new_hashes[target] = current_hash
146
- 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)}")
147
306
  continue
148
307
 
149
308
  if basename in APPENDABLE:
@@ -234,20 +393,21 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
234
393
  flags = {}
235
394
  if registries is None:
236
395
  registries = [registry] if registry else []
237
- # Make all shell scripts in scripts/ executable
238
- scripts_dir = os.path.join(".", "scripts")
239
- if os.path.isdir(scripts_dir):
240
- for entry in os.listdir(scripts_dir):
396
+ # Make all shell scripts in .rlsbl/hooks/ executable
397
+ hooks_dir = os.path.join(".", ".rlsbl", "hooks")
398
+ if os.path.isdir(hooks_dir):
399
+ for entry in os.listdir(hooks_dir):
241
400
  if entry.endswith(".sh"):
242
- os.chmod(os.path.join(scripts_dir, entry), 0o755)
401
+ os.chmod(os.path.join(hooks_dir, entry), 0o755)
243
402
 
244
- # Auto-install pre-push hook if not already present
245
- hook_source = os.path.join("scripts", "pre-push-hook.sh")
403
+ # Auto-install pre-push hook as a one-liner that delegates to the subcommand
246
404
  hook_target = os.path.join(".git", "hooks", "pre-push")
247
- if os.path.exists(hook_source) and os.path.isdir(".git"):
405
+ if os.path.isdir(".git"):
248
406
  if not os.path.exists(hook_target):
407
+ hook_content = "#!/usr/bin/env bash\nexec rlsbl pre-push-check \"$@\"\n"
249
408
  os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
250
- shutil.copy2(hook_source, hook_target)
409
+ with open(hook_target, "w", encoding="utf-8") as f:
410
+ f.write(hook_content)
251
411
  os.chmod(hook_target, 0o755)
252
412
  print("Installed pre-push hook (.git/hooks/pre-push)")
253
413
 
@@ -0,0 +1,77 @@
1
+ """Pre-push-check command: verify CHANGELOG.md has an entry for the current version."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import sys
7
+
8
+
9
+ def _detect_version():
10
+ """Detect project type and read the current version.
11
+
12
+ Returns (version_string, project_type) or (None, None) if undetectable.
13
+ """
14
+ if os.path.exists("go.mod"):
15
+ # Go projects store version in a VERSION file
16
+ version_path = "VERSION"
17
+ if os.path.exists(version_path):
18
+ with open(version_path, "r", encoding="utf-8") as f:
19
+ version = f.read().strip()
20
+ if version:
21
+ return version, "go"
22
+ return None, None
23
+
24
+ if os.path.exists("package.json"):
25
+ try:
26
+ with open("package.json", "r", encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ version = data.get("version", "")
29
+ if version:
30
+ return version, "npm"
31
+ except Exception:
32
+ pass
33
+ return None, None
34
+
35
+ if os.path.exists("pyproject.toml"):
36
+ try:
37
+ try:
38
+ import tomllib
39
+ except ModuleNotFoundError:
40
+ import tomli as tomllib # type: ignore[no-redef]
41
+ with open("pyproject.toml", "rb") as f:
42
+ data = tomllib.load(f)
43
+ version = data.get("project", {}).get("version", "")
44
+ if version:
45
+ return version, "pypi"
46
+ except Exception:
47
+ pass
48
+ return None, None
49
+
50
+ return None, None
51
+
52
+
53
+ def run_cmd(registry, args, flags):
54
+ """Check that CHANGELOG.md has an entry for the current project version.
55
+
56
+ Exits 1 if no changelog entry is found; exits 0 silently on success.
57
+ """
58
+ version, _project_type = _detect_version()
59
+ if not version:
60
+ # Cannot detect version -- nothing to check
61
+ sys.exit(0)
62
+
63
+ if not os.path.exists("CHANGELOG.md"):
64
+ # No changelog file -- nothing to check
65
+ sys.exit(0)
66
+
67
+ with open("CHANGELOG.md", "r", encoding="utf-8") as f:
68
+ content = f.read()
69
+
70
+ # Look for a heading like "## <version>"
71
+ pattern = re.compile(r"^## " + re.escape(version) + r"\s*$", re.MULTILINE)
72
+ if pattern.search(content):
73
+ sys.exit(0)
74
+
75
+ print(f"Error: CHANGELOG.md has no entry for version {version}.", file=sys.stderr)
76
+ print(f"Add a '## {version}' section before pushing.", file=sys.stderr)
77
+ sys.exit(1)
@@ -0,0 +1,86 @@
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
+ assets_dir = "assets"
47
+ os.makedirs(assets_dir, exist_ok=True)
48
+
49
+ # Create a temporary VHS tape file in the project directory
50
+ tape_content = (
51
+ 'Set FontFamily "monospace"\n'
52
+ "Set FontSize 24\n"
53
+ "Set Width 1200\n"
54
+ "Set Height 600\n"
55
+ "Set TypingSpeed 50ms\n"
56
+ f'Type "{bin_command} --help"\n'
57
+ "Enter\n"
58
+ "Sleep 3s\n"
59
+ )
60
+
61
+ tape_fd, tape_path = tempfile.mkstemp(suffix=".tape", dir=".")
62
+ try:
63
+ with os.fdopen(tape_fd, "w") as f:
64
+ f.write(tape_content)
65
+
66
+ output_path = os.path.join(assets_dir, "demo.gif")
67
+ print("Recording demo...")
68
+
69
+ subprocess.run(
70
+ ["vhs", tape_path, "-o", output_path],
71
+ check=True, timeout=120,
72
+ )
73
+
74
+ print(f"Done. GIF saved to {output_path}")
75
+ except subprocess.CalledProcessError:
76
+ print("Error: vhs recording failed.", file=sys.stderr)
77
+ sys.exit(1)
78
+ except subprocess.TimeoutExpired:
79
+ print("Error: vhs recording timed out after 120s.", file=sys.stderr)
80
+ sys.exit(1)
81
+ finally:
82
+ # Clean up the temp tape file
83
+ try:
84
+ os.unlink(tape_path)
85
+ except OSError:
86
+ pass
@@ -18,7 +18,6 @@ from ..utils import (
18
18
  is_clean_tree,
19
19
  push_if_needed,
20
20
  run,
21
- spawn_ci_watcher,
22
21
  )
23
22
 
24
23
  VALID_BUMP_TYPES = ("patch", "minor", "major")
@@ -113,7 +112,7 @@ def run_cmd(registry, args, flags):
113
112
  )
114
113
 
115
114
  # Run pre-release hook if present
116
- pre_release_script = os.path.join(".", "scripts", "pre-release.sh")
115
+ pre_release_script = os.path.join(".", ".rlsbl", "hooks", "pre-release.sh")
117
116
  if os.path.exists(pre_release_script):
118
117
  log("Running pre-release hook...")
119
118
  try:
@@ -256,7 +255,7 @@ def run_cmd(registry, args, flags):
256
255
  ensure_github_topic(quiet=quiet)
257
256
 
258
257
  # Run post-release hook if present (non-fatal: release is already complete)
259
- post_release_script = os.path.join(".", "scripts", "post-release.sh")
258
+ post_release_script = os.path.join(".", ".rlsbl", "hooks", "post-release.sh")
260
259
  if os.path.exists(post_release_script):
261
260
  log("Running post-release hook...")
262
261
  try:
@@ -266,11 +265,10 @@ def run_cmd(registry, args, flags):
266
265
  except Exception as e:
267
266
  print(f"Warning: post-release hook failed: {e}", file=sys.stderr)
268
267
 
269
- # Watch CI in the background and notify on completion
268
+ # Hint: how to watch CI for this release
270
269
  try:
271
270
  commit_sha = run("git", ["rev-parse", "HEAD"])
272
- spawn_ci_watcher(commit_sha, tag)
273
- log("Watching CI in background (will notify when done)")
271
+ log(f"Watch CI: rlsbl watch {commit_sha}")
274
272
  except Exception:
275
273
  pass
276
274
 
@@ -0,0 +1,115 @@
1
+ """Watch command: monitor CI runs for a commit and report results."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import time
8
+
9
+ from ..utils import run
10
+
11
+
12
+ def _notify(title, body):
13
+ """Send a desktop notification. Non-fatal if unavailable."""
14
+ try:
15
+ if sys.platform == "darwin":
16
+ subprocess.run(
17
+ ["osascript", "-e",
18
+ f'display notification "{body}" with title "{title}"'],
19
+ timeout=5, capture_output=True,
20
+ )
21
+ elif shutil.which("notify-send"):
22
+ subprocess.run(
23
+ ["notify-send", "-u", "normal", title, body],
24
+ timeout=5, capture_output=True,
25
+ )
26
+ except Exception:
27
+ pass
28
+
29
+
30
+ def run_cmd(registry, args, flags):
31
+ """Watch all CI runs for a commit until they complete.
32
+
33
+ Usage: rlsbl watch [<commit-sha>]
34
+ Defaults to HEAD if no commit SHA is provided.
35
+ """
36
+ # Get commit SHA
37
+ if args:
38
+ commit_sha = args[0]
39
+ else:
40
+ try:
41
+ commit_sha = run("git", ["rev-parse", "HEAD"])
42
+ except Exception:
43
+ print("Error: not a git repository and no commit SHA provided.", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+ # Get repo info for display and URLs
47
+ try:
48
+ repo_info = run("gh", ["repo", "view", "--json", "nameWithOwner,name"])
49
+ info = json.loads(repo_info)
50
+ repo_slug = info.get("nameWithOwner", "")
51
+ repo_name = info.get("name", "")
52
+ except Exception:
53
+ print("Error: could not get repo info. Is gh installed and authenticated?", file=sys.stderr)
54
+ sys.exit(1)
55
+
56
+ # Try to find a tag for this commit for nicer display
57
+ try:
58
+ tag = run("git", ["describe", "--tags", "--exact-match", commit_sha])
59
+ except Exception:
60
+ tag = commit_sha[:12]
61
+
62
+ label = f"{repo_name} {tag}" if repo_name else tag
63
+
64
+ # Poll until at least one run appears (retry up to 30s)
65
+ runs = []
66
+ for _ in range(15):
67
+ try:
68
+ raw = run("gh", ["run", "list", "--commit", commit_sha,
69
+ "--json", "databaseId,name,status"])
70
+ parsed = json.loads(raw)
71
+ if parsed:
72
+ runs = parsed
73
+ break
74
+ except Exception:
75
+ pass
76
+ time.sleep(2)
77
+
78
+ if not runs:
79
+ print(f"rlsbl: {label}: no CI runs found after 30s", file=sys.stderr)
80
+ sys.exit(1)
81
+
82
+ print(f"rlsbl: {label}: found {len(runs)} CI run(s), watching...", file=sys.stderr)
83
+
84
+ # Watch each run sequentially, collecting results
85
+ any_failed = False
86
+ for ci_run in runs:
87
+ run_id = str(ci_run["databaseId"])
88
+ workflow_name = ci_run.get("name", f"run {run_id}")
89
+
90
+ try:
91
+ # gh run watch blocks until the run completes;
92
+ # --exit-status makes it exit 1 on failure; check=True raises
93
+ # CalledProcessError so we can distinguish pass from fail
94
+ subprocess.run(
95
+ ["gh", "run", "watch", run_id, "--exit-status"],
96
+ capture_output=True, text=True, timeout=3600, check=True,
97
+ )
98
+ print(f"rlsbl: {label}: {workflow_name} passed", file=sys.stderr)
99
+ except subprocess.CalledProcessError:
100
+ any_failed = True
101
+ print(f"rlsbl: {label}: {workflow_name} FAILED", file=sys.stderr)
102
+ if repo_slug:
103
+ print(f"rlsbl: https://github.com/{repo_slug}/actions/runs/{run_id}",
104
+ file=sys.stderr)
105
+ except subprocess.TimeoutExpired:
106
+ any_failed = True
107
+ print(f"rlsbl: {label}: {workflow_name} timed out after 1h", file=sys.stderr)
108
+
109
+ # Desktop notification for overall result
110
+ if any_failed:
111
+ _notify(f"{label}: CI FAILED", "One or more workflows failed")
112
+ else:
113
+ _notify(f"{label}: CI passed", "All workflows passed")
114
+
115
+ sys.exit(1 if any_failed else 0)
@@ -107,12 +107,9 @@ def get_shared_template_mappings():
107
107
  {"template": "gitignore.tpl", "target": ".gitignore"},
108
108
  {"template": "LICENSE.tpl", "target": "LICENSE"},
109
109
  {"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
110
- {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
111
110
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
112
- {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
113
- {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
114
- {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
115
- {"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
111
+ {"template": "hooks/pre-release.sh.tpl", "target": ".rlsbl/hooks/pre-release.sh"},
112
+ {"template": "hooks/post-release.sh.tpl", "target": ".rlsbl/hooks/post-release.sh"},
116
113
  ]
117
114
 
118
115
 
@@ -103,11 +103,8 @@ def get_shared_template_mappings():
103
103
  {"template": "gitignore.tpl", "target": ".gitignore"},
104
104
  {"template": "LICENSE.tpl", "target": "LICENSE"},
105
105
  {"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
106
- {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
107
- {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
108
- {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
109
- {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
110
- {"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
106
+ {"template": "hooks/pre-release.sh.tpl", "target": ".rlsbl/hooks/pre-release.sh"},
107
+ {"template": "hooks/post-release.sh.tpl", "target": ".rlsbl/hooks/post-release.sh"},
111
108
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
112
109
  ]
113
110
 
@@ -155,11 +155,8 @@ def get_shared_template_mappings():
155
155
  {"template": "gitignore.tpl", "target": ".gitignore"},
156
156
  {"template": "LICENSE.tpl", "target": "LICENSE"},
157
157
  {"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
158
- {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
159
- {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
160
- {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
161
- {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
162
- {"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
158
+ {"template": "hooks/pre-release.sh.tpl", "target": ".rlsbl/hooks/pre-release.sh"},
159
+ {"template": "hooks/post-release.sh.tpl", "target": ".rlsbl/hooks/post-release.sh"},
163
160
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
164
161
  ]
165
162
 
package/rlsbl/utils.py CHANGED
@@ -108,112 +108,6 @@ def find_commit_tool():
108
108
  return "git"
109
109
 
110
110
 
111
- def spawn_ci_watcher(commit_sha, tag):
112
- """Spawn a detached background process that watches CI and prints results to stderr.
113
-
114
- The spawned process inherits the parent's stderr so output appears in the
115
- same terminal/stream -- important for AI agents that read stderr.
116
- Desktop notifications are sent as a secondary channel when available.
117
- """
118
- repo_slug = ""
119
- try:
120
- repo_slug = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
121
- except Exception:
122
- pass
123
-
124
- repo_name = ""
125
- try:
126
- repo_name = run("gh", ["repo", "view", "--json", "name", "-q", ".name"])
127
- except Exception:
128
- pass
129
-
130
- label = f"{repo_name} {tag}" if repo_name else tag
131
-
132
- # Build the notification snippet based on what's available on this platform
133
- notify_snippet = _notify_snippet()
134
-
135
- script = f"""
136
- import subprocess, sys, time
137
-
138
- commit_sha = {commit_sha!r}
139
- label = {label!r}
140
- repo_slug = {repo_slug!r}
141
-
142
- # Find the CI run by commit SHA (retry up to 30s)
143
- run_id = None
144
- for _ in range(15):
145
- try:
146
- r = subprocess.run(
147
- ["gh", "run", "list", "--commit", commit_sha, "--limit", "1",
148
- "--json", "databaseId", "-q", ".[0].databaseId"],
149
- capture_output=True, text=True, timeout=10)
150
- if r.returncode == 0 and r.stdout.strip():
151
- run_id = r.stdout.strip()
152
- break
153
- except Exception:
154
- pass
155
- time.sleep(2)
156
-
157
- if not run_id:
158
- sys.exit(0)
159
-
160
- # Watch the run until it completes
161
- result = subprocess.run(
162
- ["gh", "run", "watch", run_id, "--exit-status"],
163
- capture_output=True, text=True, timeout=3600)
164
-
165
- ok = result.returncode == 0
166
-
167
- # Extract last non-empty line as summary for desktop notification
168
- summary = ""
169
- for line in reversed(result.stdout.strip().splitlines()):
170
- if line.strip():
171
- summary = line.strip()
172
- break
173
-
174
- # Print result to stderr so AI agents and terminal users can see it
175
- if ok:
176
- print(f"rlsbl: {{label}}: CI passed", file=sys.stderr)
177
- else:
178
- print(f"rlsbl: {{label}}: CI FAILED", file=sys.stderr)
179
- if repo_slug and run_id:
180
- print(f"rlsbl: https://github.com/{{repo_slug}}/actions/runs/{{run_id}}", file=sys.stderr)
181
-
182
- # Desktop notification (optional, non-fatal)
183
- title = f"{{label}}: CI passed" if ok else f"{{label}}: CI FAILED"
184
- try:
185
- {notify_snippet}
186
- except Exception:
187
- pass
188
- """
189
- subprocess.Popen(
190
- [sys.executable, "-c", script],
191
- start_new_session=True,
192
- stdin=subprocess.DEVNULL,
193
- )
194
-
195
-
196
- def _notify_snippet():
197
- """Return an indented Python code snippet for sending a desktop notification.
198
-
199
- Returns a pass statement if no notification tool is available.
200
- The snippet is intended to be embedded inside a try/except block.
201
- """
202
- indent = " "
203
- if sys.platform == "darwin":
204
- return (
205
- f'{indent}subprocess.run(["osascript", "-e",\n'
206
- f'{indent} f\'display notification "{{summary}}" with title "{{title}}"\'],\n'
207
- f'{indent} timeout=5)'
208
- )
209
- if shutil.which("notify-send"):
210
- return (
211
- f'{indent}urgency = "normal" if ok else "critical"\n'
212
- f'{indent}subprocess.run(["notify-send", "-u", urgency, title, summary], timeout=5)'
213
- )
214
- return f"{indent}pass"
215
-
216
-
217
111
  def bump_version(version, bump_type):
218
112
  """Bump a semver version string by the given type (patch, minor, major).
219
113
 
@@ -1,15 +1,3 @@
1
1
  {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "./scripts/check-prs.sh"
10
- }
11
- ]
12
- }
13
- ]
14
- }
2
+ "hooks": {}
15
3
  }
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env bash
2
- # List open PRs for awareness at session start.
3
- # Safe to run in hooks -- always exits 0.
4
- cd "$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
5
- count=$(gh pr list --state open --json number --jq length 2>/dev/null) || exit 0
6
- if [ "$count" -gt 0 ]; then
7
- echo "Open PRs: $count"
8
- gh pr list --state open
9
- fi
10
- exit 0
@@ -1,44 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Pre-push hook: verify CHANGELOG.md has an entry for the current version.
3
- # Install: cp scripts/pre-push-hook.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push
4
-
5
- set -euo pipefail
6
-
7
- # Detect project type and extract version
8
- if [ -f package.json ]; then
9
- VERSION=$(node -e "console.log(require('./package.json').version)" 2>/dev/null) || exit 0
10
- elif [ -f pyproject.toml ]; then
11
- VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/.*"\(.*\)".*/\1/') || exit 0
12
- elif [ -f VERSION ]; then
13
- VERSION=$(tr -d '[:space:]' < VERSION) || exit 0
14
- else
15
- exit 0
16
- fi
17
-
18
- if [ -z "$VERSION" ]; then
19
- exit 0
20
- fi
21
-
22
- # Check CHANGELOG.md has an entry for this version
23
- if [ ! -f CHANGELOG.md ]; then
24
- echo "Warning: CHANGELOG.md not found."
25
- exit 0
26
- fi
27
-
28
- if ! grep -q "^## $VERSION" CHANGELOG.md; then
29
- echo "Error: CHANGELOG.md has no entry for version $VERSION."
30
- echo "Add a '## $VERSION' section before pushing."
31
- exit 1
32
- fi
33
-
34
- # Check scaffolding freshness
35
- if [ -f .rlsbl/version ]; then
36
- SCAFFOLD_VER=$(cat .rlsbl/version | tr -d '[:space:]')
37
- if command -v rlsbl &>/dev/null; then
38
- CURRENT_VER=$(rlsbl --version 2>/dev/null | tr -d '[:space:]')
39
- if [ -n "$CURRENT_VER" ] && [ "$SCAFFOLD_VER" != "$CURRENT_VER" ]; then
40
- echo "Warning: scaffolding was generated by rlsbl $SCAFFOLD_VER but you have $CURRENT_VER installed."
41
- echo "Run 'rlsbl scaffold --update' to update scaffolding."
42
- fi
43
- fi
44
- fi
@@ -1,34 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Record a demo GIF for README. Requires: vhs (https://github.com/charmbracelet/vhs).
3
- # Usage: ./scripts/record-gif.sh [duration_seconds]
4
- set -euo pipefail
5
-
6
- DURATION="${1:-10}"
7
- ASSETS_DIR="assets"
8
-
9
- mkdir -p "$ASSETS_DIR"
10
-
11
- if ! command -v vhs &>/dev/null; then
12
- echo "Error: vhs is required."
13
- echo "Install: go install github.com/charmbracelet/vhs@latest"
14
- exit 1
15
- fi
16
-
17
- TAPE=$(mktemp)
18
- cat > "$TAPE" <<EOF
19
- Set FontFamily "monospace"
20
- Set FontSize 24
21
- Set Width 1200
22
- Set Height 600
23
- Set TypingSpeed 50ms
24
- Type "{{binCommand}} --help"
25
- Enter
26
- Sleep 3s
27
- EOF
28
-
29
- echo "Recording demo..."
30
- vhs "$TAPE" -o "$ASSETS_DIR/demo.gif"
31
- rm -f "$TAPE"
32
-
33
- echo "Done. GIF saved to $ASSETS_DIR/demo.gif"
34
- echo "Edit this script to customize the recording."