rlsbl 0.5.1 → 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.1",
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
@@ -3,14 +3,44 @@
3
3
  import os
4
4
  import sys
5
5
 
6
- try:
7
- from importlib.metadata import version as _get_version
8
- __version__ = _get_version("rlsbl")
9
- except Exception:
10
- __version__ = "unknown"
6
+
7
+ def _detect_version():
8
+ """Detect package version, preferring pyproject.toml over installed metadata.
9
+
10
+ Order: pyproject.toml in the source tree (accurate during editable installs)
11
+ -> importlib.metadata (works for regular installs) -> "unknown".
12
+ """
13
+ # Try reading version from pyproject.toml next to the package source
14
+ try:
15
+ pyproject_path = os.path.realpath(
16
+ os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")
17
+ )
18
+ if os.path.isfile(pyproject_path):
19
+ try:
20
+ import tomllib
21
+ except ModuleNotFoundError:
22
+ import tomli as tomllib # type: ignore[no-redef]
23
+ with open(pyproject_path, "rb") as f:
24
+ data = tomllib.load(f)
25
+ return data["project"]["version"]
26
+ except Exception:
27
+ pass
28
+
29
+ # Fall back to installed dist-info metadata
30
+ try:
31
+ from importlib.metadata import version as _get_version
32
+ return _get_version("rlsbl")
33
+ except Exception:
34
+ pass
35
+
36
+ return "unknown"
37
+
38
+
39
+ __version__ = _detect_version()
11
40
 
12
41
  REGISTRIES = ("npm", "pypi", "go")
13
- 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")
14
44
  COMMAND_ALIASES = {"init": "scaffold"}
15
45
 
16
46
  HELP = f"""\
@@ -24,6 +54,10 @@ Usage:
24
54
  rlsbl config Show project configuration
25
55
  rlsbl undo [--yes] Revert the last release
26
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
27
61
 
28
62
  Options:
29
63
  --registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
@@ -89,6 +123,10 @@ def _get_command_module(command):
89
123
  "config": "config",
90
124
  "undo": "undo",
91
125
  "discover": "discover",
126
+ "watch": "watch",
127
+ "check-prs": "check_prs",
128
+ "pre-push-check": "pre_push_check",
129
+ "record-gif": "record_gif",
92
130
  }
93
131
  module_name = module_map.get(command)
94
132
  if not module_name:
@@ -145,14 +183,17 @@ def main():
145
183
  sys.exit(1)
146
184
 
147
185
  if command == "check":
148
- # check: if registry given, check that one; otherwise check all
186
+ # check: if registry given, check that one; otherwise check npm and pypi.
187
+ # Go is excluded from the default set because Go modules use repository
188
+ # paths (e.g. github.com/user/repo), not a flat claimable namespace, so
189
+ # "available" would be misleading. Pass --registry go explicitly to check.
149
190
  if registry:
150
191
  handler.run_cmd(registry, args, flags)
151
192
  else:
152
- all_registries = ["npm", "pypi", "go"]
153
- for i, r in enumerate(all_registries):
193
+ default_registries = ["npm", "pypi"]
194
+ for i, r in enumerate(default_registries):
154
195
  handler.run_cmd(r, args, flags)
155
- if i < len(all_registries) - 1:
196
+ if i < len(default_registries) - 1:
156
197
  print("")
157
198
  elif command == "scaffold":
158
199
  if registry:
@@ -182,6 +223,12 @@ def main():
182
223
  elif command == "discover":
183
224
  # discover: global query, no registry needed
184
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)
185
232
  else:
186
233
  # release, status: use explicit registry or auto-detect primary
187
234
  if not registry:
@@ -168,35 +168,44 @@ def _check_name_pypi(name):
168
168
  def check_go_availability(name):
169
169
  """Check if a Go module path exists on pkg.go.dev.
170
170
 
171
- Returns {"status": "available"|"taken"|"error", "message"?: str}.
171
+ Returns {"status": "not_found"|"exists"|"error", "message"?: str, "note"?: str}.
172
+
173
+ Go modules use repository paths (e.g. github.com/user/repo), not a flat
174
+ claimable namespace, so we report "not found" / "exists" rather than the
175
+ "available" / "taken" language used for npm and PyPI.
172
176
  """
173
177
  url = f"https://pkg.go.dev/{name}"
174
178
  try:
175
179
  req = urllib.request.Request(url, method="GET")
176
180
  with urllib.request.urlopen(req, timeout=5) as resp:
177
181
  if resp.status == 200:
178
- return {"status": "taken"}
182
+ return {"status": "exists"}
179
183
  return {"status": "error", "message": f"Unexpected status {resp.status}"}
180
184
  except urllib.error.HTTPError as e:
181
185
  if e.code == 404:
182
- return {"status": "available"}
186
+ return {
187
+ "status": "not_found",
188
+ "note": "Go modules use repository paths, not a central registry.",
189
+ }
183
190
  return {"status": "error", "message": f"Unexpected status {e.code}"}
184
191
  except Exception as e:
185
192
  return {"status": "error", "message": str(e) or "Network error"}
186
193
 
187
194
 
188
195
  def _check_name_go(name):
189
- """Check Go module path availability on pkg.go.dev."""
196
+ """Check Go module path on pkg.go.dev."""
190
197
  print(f'Checking pkg.go.dev for "{name}"...')
191
198
 
192
199
  result = check_go_availability(name)
193
200
  if result["status"] == "error":
194
201
  print(f"Error checking pkg.go.dev: {result['message']}", file=sys.stderr)
195
202
  sys.exit(1)
196
- if result["status"] == "available":
197
- print(f'"{name}" is available on pkg.go.dev.')
203
+ if result["status"] == "not_found":
204
+ print(f'"{name}" not found on pkg.go.dev.')
198
205
  else:
199
- print(f'"{name}" already exists on pkg.go.dev.')
206
+ print(f'"{name}" exists on pkg.go.dev.')
207
+ if result.get("note"):
208
+ print(f" Note: {result['note']}")
200
209
 
201
210
 
202
211
  def run_cmd(registry, args, flags):
@@ -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'}")
@@ -133,6 +133,8 @@ def run_cmd(registry, args, flags):
133
133
  repos = _fetch_all_repos(token)
134
134
  except urllib.error.HTTPError as e:
135
135
  print(f"Error: GitHub API returned {e.code}: {e.reason}", file=sys.stderr)
136
+ if e.code == 403:
137
+ print("Hint: run 'gh auth login' to increase API rate limits (60/hr unauthenticated → 5000/hr).", file=sys.stderr)
136
138
  sys.exit(1)
137
139
  except urllib.error.URLError as e:
138
140
  print(f"Error: could not reach GitHub API: {e.reason}", file=sys.stderr)
@@ -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):
@@ -177,13 +172,19 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
177
172
  existing_lines = {
178
173
  line.strip() for line in existing.split("\n") if line.strip()
179
174
  }
175
+ # Normalize by stripping trailing slashes so e.g.
176
+ # "*.egg-info/" matches "*.egg-info" and vice versa.
177
+ existing_normalized = {
178
+ line.rstrip("/") for line in existing_lines
179
+ }
180
180
  new_lines = [
181
181
  line.strip() for line in content.split("\n") if line.strip()
182
182
  ]
183
183
  # Only merge non-comment entries that are missing from the existing file
184
184
  missing = [
185
185
  line for line in new_lines
186
- if line not in existing_lines and not line.startswith("#")
186
+ if line.rstrip("/") not in existing_normalized
187
+ and not line.startswith("#")
187
188
  ]
188
189
  if missing:
189
190
  with open(target, "a", encoding="utf-8") as f:
@@ -228,20 +229,21 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
228
229
  flags = {}
229
230
  if registries is None:
230
231
  registries = [registry] if registry else []
231
- # Make all shell scripts in scripts/ executable
232
- scripts_dir = os.path.join(".", "scripts")
233
- if os.path.isdir(scripts_dir):
234
- 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):
235
236
  if entry.endswith(".sh"):
236
- os.chmod(os.path.join(scripts_dir, entry), 0o755)
237
+ os.chmod(os.path.join(hooks_dir, entry), 0o755)
237
238
 
238
- # Auto-install pre-push hook if not already present
239
- 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
240
240
  hook_target = os.path.join(".git", "hooks", "pre-push")
241
- if os.path.exists(hook_source) and os.path.isdir(".git"):
241
+ if os.path.isdir(".git"):
242
242
  if not os.path.exists(hook_target):
243
+ hook_content = "#!/usr/bin/env bash\nexec rlsbl pre-push-check \"$@\"\n"
243
244
  os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
244
- shutil.copy2(hook_source, hook_target)
245
+ with open(hook_target, "w", encoding="utf-8") as f:
246
+ f.write(hook_content)
245
247
  os.chmod(hook_target, 0o755)
246
248
  print("Installed pre-push hook (.git/hooks/pre-push)")
247
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)
@@ -86,6 +86,7 @@ def get_template_vars(dir_path):
86
86
  "author": author,
87
87
  "repoName": repo_name,
88
88
  "binCommand": short_name,
89
+ "publishSetup": "GoReleaser handles binary publishing via GitHub Actions (no secrets needed)",
89
90
  }
90
91
 
91
92
 
@@ -106,12 +107,9 @@ def get_shared_template_mappings():
106
107
  {"template": "gitignore.tpl", "target": ".gitignore"},
107
108
  {"template": "LICENSE.tpl", "target": "LICENSE"},
108
109
  {"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
109
- {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
110
110
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
111
- {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
112
- {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
113
- {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
114
- {"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"},
115
113
  ]
116
114
 
117
115
 
@@ -84,6 +84,7 @@ def get_template_vars(dir_path):
84
84
  "binCommand": bin_command,
85
85
  "author": pkg.get("author", ""),
86
86
  "repoName": repo_name,
87
+ "publishSetup": "Requires NPM_TOKEN secret on GitHub (Settings > Secrets > Actions)",
87
88
  }
88
89
 
89
90
 
@@ -102,11 +103,8 @@ def get_shared_template_mappings():
102
103
  {"template": "gitignore.tpl", "target": ".gitignore"},
103
104
  {"template": "LICENSE.tpl", "target": "LICENSE"},
104
105
  {"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
105
- {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
106
- {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
107
- {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
108
- {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
109
- {"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"},
110
108
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
111
109
  ]
112
110
 
@@ -136,6 +136,7 @@ def get_template_vars(dir_path):
136
136
  "author": author,
137
137
  "repoName": repo_name,
138
138
  "importName": import_name,
139
+ "publishSetup": "Configure Trusted Publishing on pypi.org for automated PyPI releases",
139
140
  }
140
141
 
141
142
 
@@ -154,11 +155,8 @@ def get_shared_template_mappings():
154
155
  {"template": "gitignore.tpl", "target": ".gitignore"},
155
156
  {"template": "LICENSE.tpl", "target": "LICENSE"},
156
157
  {"template": "CLAUDE.md.tpl", "target": "CLAUDE.md"},
157
- {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
158
- {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
159
- {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
160
- {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
161
- {"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"},
162
160
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
163
161
  ]
164
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
 
@@ -13,8 +13,8 @@ jobs:
13
13
  matrix:
14
14
  go-version: ["1.22", "1.23", "1.24"]
15
15
  steps:
16
- - uses: actions/checkout@v5
17
- - uses: actions/setup-go@v5
16
+ - uses: actions/checkout@v6
17
+ - uses: actions/setup-go@v6
18
18
  with:
19
19
  go-version: ${{ matrix.go-version }}
20
20
  - run: go test ./...
@@ -11,15 +11,15 @@ jobs:
11
11
  goreleaser:
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
- - uses: actions/checkout@v5
14
+ - uses: actions/checkout@v6
15
15
  with:
16
16
  fetch-depth: 0
17
- - uses: actions/setup-go@v5
17
+ - uses: actions/setup-go@v6
18
18
  with:
19
19
  go-version-file: go.mod
20
- - uses: goreleaser/goreleaser-action@v6
20
+ - uses: goreleaser/goreleaser-action@v7
21
21
  with:
22
- version: latest
22
+ version: "~> v2"
23
23
  args: release --clean
24
24
  env:
25
25
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -12,8 +12,8 @@ jobs:
12
12
  npm:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v5
16
- - uses: actions/setup-node@v5
15
+ - uses: actions/checkout@v6
16
+ - uses: actions/setup-node@v6
17
17
  with:
18
18
  node-version: 22
19
19
  registry-url: https://registry.npmjs.org
@@ -24,7 +24,7 @@ jobs:
24
24
  pypi:
25
25
  runs-on: ubuntu-latest
26
26
  steps:
27
- - uses: actions/checkout@v5
27
+ - uses: actions/checkout@v6
28
28
  - uses: astral-sh/setup-uv@v7
29
29
  - run: uv build
30
30
  - uses: pypa/gh-action-pypi-publish@release/v1
@@ -13,8 +13,8 @@ jobs:
13
13
  matrix:
14
14
  node-version: [18, 20, 22]
15
15
  steps:
16
- - uses: actions/checkout@v5
17
- - uses: actions/setup-node@v5
16
+ - uses: actions/checkout@v6
17
+ - uses: actions/setup-node@v6
18
18
  with:
19
19
  node-version: ${{ matrix.node-version }}
20
20
  - run: node -e "require('./package.json')"
@@ -12,8 +12,8 @@ jobs:
12
12
  publish:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v5
16
- - uses: actions/setup-node@v5
15
+ - uses: actions/checkout@v6
16
+ - uses: actions/setup-node@v6
17
17
  with:
18
18
  node-version: 22
19
19
  registry-url: https://registry.npmjs.org
@@ -13,7 +13,7 @@ jobs:
13
13
  matrix:
14
14
  python-version: ["3.12", "3.13", "3.14"]
15
15
  steps:
16
- - uses: actions/checkout@v5
16
+ - uses: actions/checkout@v6
17
17
  - uses: astral-sh/setup-uv@v7
18
18
  - run: uv python install ${{ matrix.python-version }}
19
19
  - run: uv sync
@@ -12,7 +12,7 @@ jobs:
12
12
  publish:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v5
15
+ - uses: actions/checkout@v6
16
16
  - uses: astral-sh/setup-uv@v7
17
17
  - run: uv build
18
18
  - uses: pypa/gh-action-pypi-publish@release/v1
@@ -8,7 +8,7 @@ This project uses [rlsbl](https://github.com/smm-h/rlsbl) for release orchestrat
8
8
  - Run `rlsbl release [patch|minor|major]` to bump version and create a GitHub Release
9
9
  - CI handles publishing automatically via the publish workflow
10
10
  - Never publish manually — always use `rlsbl release`
11
- - Requires `NPM_TOKEN` secret on GitHub (for npm projects)
11
+ - {{publishSetup}}
12
12
  - Use `rlsbl release --dry-run` to preview a release without making changes
13
13
 
14
14
  ## Conventions
@@ -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 /tmp/record-XXXX.tape)
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."