rlsbl 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -12
- package/package.json +1 -1
- package/rlsbl/__init__.py +14 -1
- package/rlsbl/commands/config.py +2 -2
- package/rlsbl/commands/init_cmd.py +203 -43
- 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 -13
- 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 `record-gif` and `pre-push-check` functionality is provided as built-in subcommands (`rlsbl record-gif`, `rlsbl pre-push-check`) rather than scaffolded scripts.
|
|
151
149
|
|
|
152
150
|
The scaffolded `.gitignore` includes a `*.local-only` pattern. Create a `.local-only/` directory or rename files with a `.local-only` suffix to keep them out of version control -- useful for local-only assets, experiments, and keeping the working tree clean for tools that check `git status`.
|
|
153
151
|
|
|
154
152
|
## Pre-push hook
|
|
155
153
|
|
|
156
|
-
The
|
|
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
|
+
"pre-push-check", "record-gif")
|
|
43
44
|
COMMAND_ALIASES = {"init": "scaffold"}
|
|
44
45
|
|
|
45
46
|
HELP = f"""\
|
|
@@ -53,6 +54,9 @@ Usage:
|
|
|
53
54
|
rlsbl config Show project configuration
|
|
54
55
|
rlsbl undo [--yes] Revert the last release
|
|
55
56
|
rlsbl discover [--mine] List rlsbl ecosystem projects
|
|
57
|
+
rlsbl watch [<commit-sha>] Watch CI runs for a commit
|
|
58
|
+
rlsbl pre-push-check Verify CHANGELOG entry for current version
|
|
59
|
+
rlsbl record-gif Record a demo GIF with vhs
|
|
56
60
|
|
|
57
61
|
Options:
|
|
58
62
|
--registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
|
|
@@ -118,6 +122,9 @@ def _get_command_module(command):
|
|
|
118
122
|
"config": "config",
|
|
119
123
|
"undo": "undo",
|
|
120
124
|
"discover": "discover",
|
|
125
|
+
"watch": "watch",
|
|
126
|
+
"pre-push-check": "pre_push_check",
|
|
127
|
+
"record-gif": "record_gif",
|
|
121
128
|
}
|
|
122
129
|
module_name = module_map.get(command)
|
|
123
130
|
if not module_name:
|
|
@@ -214,6 +221,12 @@ def main():
|
|
|
214
221
|
elif command == "discover":
|
|
215
222
|
# discover: global query, no registry needed
|
|
216
223
|
handler.run_cmd(registry, args, flags)
|
|
224
|
+
elif command == "watch":
|
|
225
|
+
# watch: monitors CI runs, no registry needed
|
|
226
|
+
handler.run_cmd(registry, args, flags)
|
|
227
|
+
elif command in ("pre-push-check", "record-gif"):
|
|
228
|
+
# Standalone commands, no registry needed
|
|
229
|
+
handler.run_cmd(registry, args, flags)
|
|
217
230
|
else:
|
|
218
231
|
# release, status: use explicit registry or auto-detect primary
|
|
219
232
|
if not registry:
|
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,9 +4,10 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
-
import shutil
|
|
8
|
-
import stat
|
|
9
7
|
import sys
|
|
8
|
+
from io import StringIO
|
|
9
|
+
|
|
10
|
+
from ruamel.yaml import YAML
|
|
10
11
|
|
|
11
12
|
from ..config import should_tag
|
|
12
13
|
from ..registries import REGISTRIES
|
|
@@ -21,13 +22,19 @@ APPEND_MARKER = "rlsbl"
|
|
|
21
22
|
# Files where missing entries from the template are merged into the existing file
|
|
22
23
|
MERGEABLE = {".gitignore"}
|
|
23
24
|
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
# JSON files where template keys are deep-merged into existing user content
|
|
26
|
+
JSON_MERGEABLE = {".claude/settings.json"}
|
|
27
|
+
|
|
28
|
+
# YAML workflow files with job-level merge: template jobs overwrite same-key user jobs,
|
|
29
|
+
# user-added jobs are preserved. Top-level keys (name, on, permissions) come from template.
|
|
30
|
+
YAML_MERGEABLE = {".github/workflows/ci.yml", ".github/workflows/publish.yml"}
|
|
31
|
+
|
|
32
|
+
# Files owned by the user after initial scaffold -- never overwrite or merge
|
|
33
|
+
USER_OWNED = {
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
".rlsbl/hooks/pre-release.sh",
|
|
37
|
+
".rlsbl/hooks/post-release.sh",
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
def file_hash(path):
|
|
@@ -89,6 +96,100 @@ def process_template(template_content, vars_dict):
|
|
|
89
96
|
return content, unreplaced
|
|
90
97
|
|
|
91
98
|
|
|
99
|
+
def _deep_merge(base, override):
|
|
100
|
+
"""Deep-merge two JSON-like structures. User values (base) take precedence.
|
|
101
|
+
|
|
102
|
+
- Dicts: recursively merge; keys only in override are added, base keys kept.
|
|
103
|
+
- Lists: union by value; base items first, then new override items (deduplicated).
|
|
104
|
+
- Scalars: base wins.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(base, dict) and isinstance(override, dict):
|
|
107
|
+
merged = {}
|
|
108
|
+
for key in base:
|
|
109
|
+
if key in override:
|
|
110
|
+
merged[key] = _deep_merge(base[key], override[key])
|
|
111
|
+
else:
|
|
112
|
+
merged[key] = base[key]
|
|
113
|
+
for key in override:
|
|
114
|
+
if key not in base:
|
|
115
|
+
merged[key] = override[key]
|
|
116
|
+
return merged
|
|
117
|
+
if isinstance(base, list) and isinstance(override, list):
|
|
118
|
+
# Deduplicate by value: keep base order, append new items from override
|
|
119
|
+
seen = set()
|
|
120
|
+
for item in base:
|
|
121
|
+
# Use JSON serialization for unhashable items (dicts, lists)
|
|
122
|
+
try:
|
|
123
|
+
seen.add(item)
|
|
124
|
+
except TypeError:
|
|
125
|
+
seen.add(json.dumps(item, sort_keys=True))
|
|
126
|
+
result = list(base)
|
|
127
|
+
for item in override:
|
|
128
|
+
if isinstance(item, (dict, list)):
|
|
129
|
+
key = json.dumps(item, sort_keys=True)
|
|
130
|
+
else:
|
|
131
|
+
key = item
|
|
132
|
+
if key not in seen:
|
|
133
|
+
seen.add(key)
|
|
134
|
+
result.append(item)
|
|
135
|
+
return result
|
|
136
|
+
# Scalars: base (user) wins
|
|
137
|
+
return base
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _yaml_merge_workflow(existing_text, template_text):
|
|
141
|
+
"""Merge a YAML workflow at the job level.
|
|
142
|
+
|
|
143
|
+
Template wins for top-level keys (name, on, permissions, etc.).
|
|
144
|
+
For the 'jobs' key: template jobs overwrite same-key user jobs,
|
|
145
|
+
user-added jobs not in the template are preserved at the end.
|
|
146
|
+
|
|
147
|
+
Returns (merged_text, error_msg). error_msg is None on success.
|
|
148
|
+
"""
|
|
149
|
+
yaml = YAML(typ="rt")
|
|
150
|
+
yaml.preserve_quotes = True
|
|
151
|
+
try:
|
|
152
|
+
existing = yaml.load(existing_text)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return None, f"failed to parse existing YAML: {e}"
|
|
155
|
+
try:
|
|
156
|
+
template = yaml.load(template_text)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return None, f"failed to parse template YAML: {e}"
|
|
159
|
+
|
|
160
|
+
if not isinstance(existing, dict) or not isinstance(template, dict):
|
|
161
|
+
return None, "YAML root is not a mapping"
|
|
162
|
+
|
|
163
|
+
# Start with template as the base (template wins for top-level keys)
|
|
164
|
+
merged = type(template)()
|
|
165
|
+
for key in template:
|
|
166
|
+
merged[key] = template[key]
|
|
167
|
+
|
|
168
|
+
# Merge jobs: template jobs first (overwriting user jobs with same key),
|
|
169
|
+
# then append user-only jobs
|
|
170
|
+
template_jobs = template.get("jobs") or {}
|
|
171
|
+
existing_jobs = existing.get("jobs") or {}
|
|
172
|
+
|
|
173
|
+
merged_jobs = type(template_jobs)() if template_jobs else type(existing_jobs)()
|
|
174
|
+
# Template jobs in template order
|
|
175
|
+
for job_key in template_jobs:
|
|
176
|
+
merged_jobs[job_key] = template_jobs[job_key]
|
|
177
|
+
# User-added jobs not in template, preserved at the end
|
|
178
|
+
for job_key in existing_jobs:
|
|
179
|
+
if job_key not in template_jobs:
|
|
180
|
+
merged_jobs[job_key] = existing_jobs[job_key]
|
|
181
|
+
merged["jobs"] = merged_jobs
|
|
182
|
+
|
|
183
|
+
# Preserve any user top-level keys not in the template (rare, but safe)
|
|
184
|
+
for key in existing:
|
|
185
|
+
if key not in merged:
|
|
186
|
+
merged[key] = existing[key]
|
|
187
|
+
|
|
188
|
+
buf = StringIO()
|
|
189
|
+
yaml.dump(merged, buf)
|
|
190
|
+
return buf.getvalue(), None
|
|
191
|
+
|
|
192
|
+
|
|
92
193
|
def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
93
194
|
existing_hashes=None):
|
|
94
195
|
"""Process a list of template mappings: read each template, apply vars, write target files.
|
|
@@ -96,9 +197,8 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
96
197
|
Skips existing files unless force is True, with special handling:
|
|
97
198
|
- APPENDABLE files: append template sections if the marker is not already present
|
|
98
199
|
- MERGEABLE files: merge missing entries from the template into the existing file
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
200
|
+
- JSON_MERGEABLE files: deep-merge template JSON into existing (user values preserved)
|
|
201
|
+
- YAML_MERGEABLE files: job-level merge for CI workflows (template jobs win, user jobs preserved)
|
|
102
202
|
Returns (created, skipped, warnings, new_hashes).
|
|
103
203
|
"""
|
|
104
204
|
if existing_hashes is None:
|
|
@@ -121,29 +221,88 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
121
221
|
if os.path.exists(target) and not force:
|
|
122
222
|
basename = os.path.basename(target)
|
|
123
223
|
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
with open(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
224
|
+
# User-owned files are never touched after initial scaffold,
|
|
225
|
+
# except LICENSE gets its copyright year updated on --update.
|
|
226
|
+
if target in USER_OWNED:
|
|
227
|
+
if update and target == "LICENSE":
|
|
228
|
+
from datetime import datetime
|
|
229
|
+
current_year = str(datetime.now().year)
|
|
230
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
231
|
+
content = f.read()
|
|
232
|
+
# Match "Copyright (c) YYYY" or "Copyright (c) YYYY-YYYY"
|
|
233
|
+
updated = re.sub(
|
|
234
|
+
r"(Copyright\s+\(c\)\s+\d{4})-(\d{4})",
|
|
235
|
+
lambda m: (
|
|
236
|
+
m.group(0) if m.group(2) == current_year
|
|
237
|
+
else f"{m.group(1)}-{current_year}"
|
|
238
|
+
),
|
|
239
|
+
content,
|
|
240
|
+
)
|
|
241
|
+
if updated == content:
|
|
242
|
+
# No range found or range already current -- try single year
|
|
243
|
+
updated = re.sub(
|
|
244
|
+
r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
|
|
245
|
+
lambda m: (
|
|
246
|
+
m.group(0) if m.group(2) == current_year
|
|
247
|
+
else f"{m.group(1)}{m.group(2)}-{current_year}"
|
|
248
|
+
),
|
|
249
|
+
content,
|
|
250
|
+
)
|
|
251
|
+
if updated != content:
|
|
252
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
253
|
+
f.write(updated)
|
|
254
|
+
created.append("LICENSE (year updated)")
|
|
255
|
+
else:
|
|
256
|
+
skipped.append(target)
|
|
142
257
|
else:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
258
|
+
skipped.append(target)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
if target in JSON_MERGEABLE:
|
|
262
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
263
|
+
existing_text = f.read()
|
|
264
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
265
|
+
raw = f.read()
|
|
266
|
+
content, unreplaced = process_template(raw, vars_dict)
|
|
267
|
+
try:
|
|
268
|
+
existing_data = json.loads(existing_text)
|
|
269
|
+
template_data = json.loads(content)
|
|
270
|
+
except json.JSONDecodeError as e:
|
|
271
|
+
warnings.append(f"{target}: JSON parse error during merge: {e}")
|
|
272
|
+
skipped.append(target)
|
|
273
|
+
continue
|
|
274
|
+
merged = _deep_merge(existing_data, template_data)
|
|
275
|
+
target_dir = os.path.dirname(target)
|
|
276
|
+
if target_dir and target_dir != ".":
|
|
277
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
278
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
279
|
+
f.write(json.dumps(merged, indent=2))
|
|
280
|
+
f.write("\n")
|
|
281
|
+
created.append(target + " (merged)")
|
|
282
|
+
if unreplaced:
|
|
283
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if target in YAML_MERGEABLE:
|
|
287
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
288
|
+
existing_text = f.read()
|
|
289
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
290
|
+
raw = f.read()
|
|
291
|
+
content, unreplaced = process_template(raw, vars_dict)
|
|
292
|
+
merged_text, err = _yaml_merge_workflow(existing_text, content)
|
|
293
|
+
if err:
|
|
294
|
+
warnings.append(f"{target}: YAML merge skipped: {err}")
|
|
295
|
+
skipped.append(target)
|
|
296
|
+
continue
|
|
297
|
+
target_dir = os.path.dirname(target)
|
|
298
|
+
if target_dir and target_dir != ".":
|
|
299
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
300
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
301
|
+
f.write(merged_text)
|
|
302
|
+
new_hashes[target] = file_hash(target)
|
|
303
|
+
created.append(target + " (merged)")
|
|
304
|
+
if unreplaced:
|
|
305
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
147
306
|
continue
|
|
148
307
|
|
|
149
308
|
if basename in APPENDABLE:
|
|
@@ -234,20 +393,21 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
|
|
|
234
393
|
flags = {}
|
|
235
394
|
if registries is None:
|
|
236
395
|
registries = [registry] if registry else []
|
|
237
|
-
# Make all shell scripts in
|
|
238
|
-
|
|
239
|
-
if os.path.isdir(
|
|
240
|
-
for entry in os.listdir(
|
|
396
|
+
# Make all shell scripts in .rlsbl/hooks/ executable
|
|
397
|
+
hooks_dir = os.path.join(".", ".rlsbl", "hooks")
|
|
398
|
+
if os.path.isdir(hooks_dir):
|
|
399
|
+
for entry in os.listdir(hooks_dir):
|
|
241
400
|
if entry.endswith(".sh"):
|
|
242
|
-
os.chmod(os.path.join(
|
|
401
|
+
os.chmod(os.path.join(hooks_dir, entry), 0o755)
|
|
243
402
|
|
|
244
|
-
# Auto-install pre-push hook
|
|
245
|
-
hook_source = os.path.join("scripts", "pre-push-hook.sh")
|
|
403
|
+
# Auto-install pre-push hook as a one-liner that delegates to the subcommand
|
|
246
404
|
hook_target = os.path.join(".git", "hooks", "pre-push")
|
|
247
|
-
if os.path.
|
|
405
|
+
if os.path.isdir(".git"):
|
|
248
406
|
if not os.path.exists(hook_target):
|
|
407
|
+
hook_content = "#!/usr/bin/env bash\nexec rlsbl pre-push-check \"$@\"\n"
|
|
249
408
|
os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
|
|
250
|
-
|
|
409
|
+
with open(hook_target, "w", encoding="utf-8") as f:
|
|
410
|
+
f.write(hook_content)
|
|
251
411
|
os.chmod(hook_target, 0o755)
|
|
252
412
|
print("Installed pre-push hook (.git/hooks/pre-push)")
|
|
253
413
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Pre-push-check command: verify CHANGELOG.md has an entry for the current version."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _detect_version():
|
|
10
|
+
"""Detect project type and read the current version.
|
|
11
|
+
|
|
12
|
+
Returns (version_string, project_type) or (None, None) if undetectable.
|
|
13
|
+
"""
|
|
14
|
+
if os.path.exists("go.mod"):
|
|
15
|
+
# Go projects store version in a VERSION file
|
|
16
|
+
version_path = "VERSION"
|
|
17
|
+
if os.path.exists(version_path):
|
|
18
|
+
with open(version_path, "r", encoding="utf-8") as f:
|
|
19
|
+
version = f.read().strip()
|
|
20
|
+
if version:
|
|
21
|
+
return version, "go"
|
|
22
|
+
return None, None
|
|
23
|
+
|
|
24
|
+
if os.path.exists("package.json"):
|
|
25
|
+
try:
|
|
26
|
+
with open("package.json", "r", encoding="utf-8") as f:
|
|
27
|
+
data = json.load(f)
|
|
28
|
+
version = data.get("version", "")
|
|
29
|
+
if version:
|
|
30
|
+
return version, "npm"
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
return None, None
|
|
34
|
+
|
|
35
|
+
if os.path.exists("pyproject.toml"):
|
|
36
|
+
try:
|
|
37
|
+
try:
|
|
38
|
+
import tomllib
|
|
39
|
+
except ModuleNotFoundError:
|
|
40
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
41
|
+
with open("pyproject.toml", "rb") as f:
|
|
42
|
+
data = tomllib.load(f)
|
|
43
|
+
version = data.get("project", {}).get("version", "")
|
|
44
|
+
if version:
|
|
45
|
+
return version, "pypi"
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return None, None
|
|
49
|
+
|
|
50
|
+
return None, None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_cmd(registry, args, flags):
|
|
54
|
+
"""Check that CHANGELOG.md has an entry for the current project version.
|
|
55
|
+
|
|
56
|
+
Exits 1 if no changelog entry is found; exits 0 silently on success.
|
|
57
|
+
"""
|
|
58
|
+
version, _project_type = _detect_version()
|
|
59
|
+
if not version:
|
|
60
|
+
# Cannot detect version -- nothing to check
|
|
61
|
+
sys.exit(0)
|
|
62
|
+
|
|
63
|
+
if not os.path.exists("CHANGELOG.md"):
|
|
64
|
+
# No changelog file -- nothing to check
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
with open("CHANGELOG.md", "r", encoding="utf-8") as f:
|
|
68
|
+
content = f.read()
|
|
69
|
+
|
|
70
|
+
# Look for a heading like "## <version>"
|
|
71
|
+
pattern = re.compile(r"^## " + re.escape(version) + r"\s*$", re.MULTILINE)
|
|
72
|
+
if pattern.search(content):
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
|
|
75
|
+
print(f"Error: CHANGELOG.md has no entry for version {version}.", file=sys.stderr)
|
|
76
|
+
print(f"Add a '## {version}' section before pushing.", file=sys.stderr)
|
|
77
|
+
sys.exit(1)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Record-gif command: record a demo GIF using vhs."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from .. import detect_registries
|
|
10
|
+
from ..registries import REGISTRIES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_bin_command():
|
|
14
|
+
"""Auto-detect the project's binary command name via registry template vars."""
|
|
15
|
+
regs = detect_registries()
|
|
16
|
+
if not regs:
|
|
17
|
+
return None
|
|
18
|
+
# Use the first detected registry
|
|
19
|
+
registry_module = REGISTRIES.get(regs[0])
|
|
20
|
+
if not registry_module:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
tvars = registry_module.get_template_vars(".")
|
|
24
|
+
return tvars.get("binCommand") or None
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_cmd(registry, args, flags):
|
|
30
|
+
"""Record a demo GIF of '<binCommand> --help' using vhs.
|
|
31
|
+
|
|
32
|
+
Requires vhs (https://github.com/charmbracelet/vhs) to be installed.
|
|
33
|
+
Output is saved to assets/demo.gif.
|
|
34
|
+
"""
|
|
35
|
+
if not shutil.which("vhs"):
|
|
36
|
+
print("Error: vhs is required.", file=sys.stderr)
|
|
37
|
+
print("Install: go install github.com/charmbracelet/vhs@latest", file=sys.stderr)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
bin_command = _get_bin_command()
|
|
41
|
+
if not bin_command:
|
|
42
|
+
print("Error: could not detect project binary command.", file=sys.stderr)
|
|
43
|
+
print("Ensure package.json, pyproject.toml, or go.mod exists with a CLI entry point.", file=sys.stderr)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
assets_dir = "assets"
|
|
47
|
+
os.makedirs(assets_dir, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
# Create a temporary VHS tape file in the project directory
|
|
50
|
+
tape_content = (
|
|
51
|
+
'Set FontFamily "monospace"\n'
|
|
52
|
+
"Set FontSize 24\n"
|
|
53
|
+
"Set Width 1200\n"
|
|
54
|
+
"Set Height 600\n"
|
|
55
|
+
"Set TypingSpeed 50ms\n"
|
|
56
|
+
f'Type "{bin_command} --help"\n'
|
|
57
|
+
"Enter\n"
|
|
58
|
+
"Sleep 3s\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
tape_fd, tape_path = tempfile.mkstemp(suffix=".tape", dir=".")
|
|
62
|
+
try:
|
|
63
|
+
with os.fdopen(tape_fd, "w") as f:
|
|
64
|
+
f.write(tape_content)
|
|
65
|
+
|
|
66
|
+
output_path = os.path.join(assets_dir, "demo.gif")
|
|
67
|
+
print("Recording demo...")
|
|
68
|
+
|
|
69
|
+
subprocess.run(
|
|
70
|
+
["vhs", tape_path, "-o", output_path],
|
|
71
|
+
check=True, timeout=120,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
print(f"Done. GIF saved to {output_path}")
|
|
75
|
+
except subprocess.CalledProcessError:
|
|
76
|
+
print("Error: vhs recording failed.", file=sys.stderr)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
print("Error: vhs recording timed out after 120s.", file=sys.stderr)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
finally:
|
|
82
|
+
# Clean up the temp tape file
|
|
83
|
+
try:
|
|
84
|
+
os.unlink(tape_path)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
@@ -18,7 +18,6 @@ from ..utils import (
|
|
|
18
18
|
is_clean_tree,
|
|
19
19
|
push_if_needed,
|
|
20
20
|
run,
|
|
21
|
-
spawn_ci_watcher,
|
|
22
21
|
)
|
|
23
22
|
|
|
24
23
|
VALID_BUMP_TYPES = ("patch", "minor", "major")
|
|
@@ -113,7 +112,7 @@ def run_cmd(registry, args, flags):
|
|
|
113
112
|
)
|
|
114
113
|
|
|
115
114
|
# Run pre-release hook if present
|
|
116
|
-
pre_release_script = os.path.join(".", "
|
|
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
|