rlsbl 0.5.2 → 0.6.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 `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.
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.6.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
+ "check-prs", "pre-push-check", "record-gif")
43
44
  COMMAND_ALIASES = {"init": "scaffold"}
44
45
 
45
46
  HELP = f"""\
@@ -53,6 +54,10 @@ 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 check-prs List open PRs (informational)
59
+ rlsbl pre-push-check Verify CHANGELOG entry for current version
60
+ rlsbl record-gif Record a demo GIF with vhs
56
61
 
57
62
  Options:
58
63
  --registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
@@ -118,6 +123,10 @@ def _get_command_module(command):
118
123
  "config": "config",
119
124
  "undo": "undo",
120
125
  "discover": "discover",
126
+ "watch": "watch",
127
+ "check-prs": "check_prs",
128
+ "pre-push-check": "pre_push_check",
129
+ "record-gif": "record_gif",
121
130
  }
122
131
  module_name = module_map.get(command)
123
132
  if not module_name:
@@ -214,6 +223,12 @@ def main():
214
223
  elif command == "discover":
215
224
  # discover: global query, no registry needed
216
225
  handler.run_cmd(registry, args, flags)
226
+ elif command == "watch":
227
+ # watch: monitors CI runs, no registry needed
228
+ handler.run_cmd(registry, args, flags)
229
+ elif command in ("check-prs", "pre-push-check", "record-gif"):
230
+ # Standalone commands, no registry needed
231
+ handler.run_cmd(registry, args, flags)
217
232
  else:
218
233
  # release, status: use explicit registry or auto-detect primary
219
234
  if not registry:
@@ -0,0 +1,26 @@
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)
@@ -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,8 +4,6 @@ import hashlib
4
4
  import json
5
5
  import os
6
6
  import re
7
- import shutil
8
- import stat
9
7
  import sys
10
8
 
11
9
  from ..config import should_tag
@@ -25,9 +23,6 @@ MERGEABLE = {".gitignore"}
25
23
  UPDATABLE = {
26
24
  ".github/workflows/ci.yml",
27
25
  ".github/workflows/publish.yml",
28
- "scripts/check-prs.sh",
29
- "scripts/post-release.sh",
30
- "scripts/pre-push-hook.sh",
31
26
  }
32
27
 
33
28
  def file_hash(path):
@@ -234,20 +229,21 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
234
229
  flags = {}
235
230
  if registries is None:
236
231
  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):
232
+ # Make all shell scripts in .rlsbl/hooks/ executable
233
+ hooks_dir = os.path.join(".", ".rlsbl", "hooks")
234
+ if os.path.isdir(hooks_dir):
235
+ for entry in os.listdir(hooks_dir):
241
236
  if entry.endswith(".sh"):
242
- os.chmod(os.path.join(scripts_dir, entry), 0o755)
237
+ os.chmod(os.path.join(hooks_dir, entry), 0o755)
243
238
 
244
- # Auto-install pre-push hook if not already present
245
- hook_source = os.path.join("scripts", "pre-push-hook.sh")
239
+ # Auto-install pre-push hook as a one-liner that delegates to the subcommand
246
240
  hook_target = os.path.join(".git", "hooks", "pre-push")
247
- if os.path.exists(hook_source) and os.path.isdir(".git"):
241
+ if os.path.isdir(".git"):
248
242
  if not os.path.exists(hook_target):
243
+ hook_content = "#!/usr/bin/env bash\nexec rlsbl pre-push-check \"$@\"\n"
249
244
  os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
250
- shutil.copy2(hook_source, hook_target)
245
+ with open(hook_target, "w", encoding="utf-8") as f:
246
+ f.write(hook_content)
251
247
  os.chmod(hook_target, 0o755)
252
248
  print("Installed pre-push hook (.git/hooks/pre-push)")
253
249
 
@@ -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
 
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "./scripts/check-prs.sh"
9
+ "command": "rlsbl check-prs"
10
10
  }
11
11
  ]
12
12
  }
@@ -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."