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 +10 -12
- package/package.json +1 -1
- package/rlsbl/__init__.py +16 -1
- package/rlsbl/commands/check_prs.py +26 -0
- package/rlsbl/commands/config.py +2 -2
- package/rlsbl/commands/init_cmd.py +10 -14
- package/rlsbl/commands/pre_push_check.py +77 -0
- package/rlsbl/commands/record_gif.py +86 -0
- package/rlsbl/commands/release.py +4 -6
- package/rlsbl/commands/watch.py +115 -0
- package/rlsbl/registries/go.py +2 -5
- package/rlsbl/registries/npm.py +2 -5
- package/rlsbl/registries/pypi.py +2 -5
- package/rlsbl/utils.py +0 -106
- package/templates/shared/claude-settings.json.tpl +1 -1
- package/templates/shared/check-prs.sh.tpl +0 -10
- package/templates/shared/pre-push-hook.sh.tpl +0 -44
- package/templates/shared/record-gif.sh.tpl +0 -34
- /package/templates/shared/{post-release.sh.tpl → hooks/post-release.sh.tpl} +0 -0
- /package/templates/shared/{pre-release.sh.tpl → hooks/pre-release.sh.tpl} +0 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
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)
|
package/rlsbl/commands/config.py
CHANGED
|
@@ -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("
|
|
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("
|
|
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
|
|
238
|
-
|
|
239
|
-
if os.path.isdir(
|
|
240
|
-
for entry in os.listdir(
|
|
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(
|
|
237
|
+
os.chmod(os.path.join(hooks_dir, entry), 0o755)
|
|
243
238
|
|
|
244
|
-
# Auto-install pre-push hook
|
|
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.
|
|
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
|
-
|
|
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(".", "
|
|
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(".", "
|
|
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
|
-
#
|
|
268
|
+
# Hint: how to watch CI for this release
|
|
270
269
|
try:
|
|
271
270
|
commit_sha = run("git", ["rev-parse", "HEAD"])
|
|
272
|
-
|
|
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)
|
package/rlsbl/registries/go.py
CHANGED
|
@@ -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": "
|
|
113
|
-
{"template": "
|
|
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
|
|
package/rlsbl/registries/npm.py
CHANGED
|
@@ -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": "
|
|
107
|
-
{"template": "
|
|
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
|
|
package/rlsbl/registries/pypi.py
CHANGED
|
@@ -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": "
|
|
159
|
-
{"template": "
|
|
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,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."
|
|
File without changes
|
|
File without changes
|