rlsbl 0.4.1 → 0.5.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
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="logo.svg" alt="rlsbl" width="336" height="105">
3
+ </p>
4
+
1
5
  # rlsbl
2
6
 
3
7
  Release orchestration and project scaffolding CLI for npm, PyPI, and Go.
@@ -80,6 +84,31 @@ rlsbl check my-cool-lib --registry npm # npm only
80
84
 
81
85
  npm checks variant spellings (hyphens, underscores, dots, no separator). PyPI normalizes per PEP 503 and checks common alternatives.
82
86
 
87
+ ### discover [--mine]
88
+
89
+ Lists all projects in the rlsbl ecosystem by querying GitHub for repositories with the `rlsbl` topic.
90
+
91
+ ```
92
+ rlsbl discover
93
+ rlsbl discover --mine # only your repos
94
+ ```
95
+
96
+ Uses GitHub token if available (higher rate limit). Works unauthenticated for public repos.
97
+
98
+ ### Ecosystem tagging
99
+
100
+ By default, `scaffold` and `release` add an `"rlsbl"` keyword to `package.json` and/or `pyproject.toml`, and set the `rlsbl` topic on the GitHub repository. This makes projects discoverable via `rlsbl discover`.
101
+
102
+ To disable tagging:
103
+
104
+ | Method | Scope |
105
+ |---|---|
106
+ | `--no-tag` flag | Single invocation |
107
+ | `{"tag": false}` in `.rlsbl/config.json` | This project |
108
+ | `{"tag": false}` in `~/.rlsbl/config.json` | All your projects |
109
+
110
+ Precedence: CLI flag > project config > user config > default (enabled).
111
+
83
112
  Global flags: `--help`, `--version`.
84
113
 
85
114
  ## Release flow
@@ -146,6 +175,12 @@ The first version must be published manually before CI can take over:
146
175
 
147
176
  After configuration, all subsequent releases are handled by CI when `rlsbl release` creates a GitHub Release. Go projects use GoReleaser in CI (via GitHub Actions) to build cross-platform binaries.
148
177
 
178
+ ## Environment variables
179
+
180
+ | Variable | Default | Description |
181
+ |----------|---------|-------------|
182
+ | `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. |
183
+
149
184
  ## Requirements
150
185
 
151
186
  - Node 18+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "pypi",
25
25
  "publish",
26
26
  "cli",
27
- "scaffold"
27
+ "scaffold",
28
+ "rlsbl"
28
29
  ],
29
30
  "author": "smm-h"
30
31
  }
package/rlsbl/__init__.py CHANGED
@@ -10,7 +10,7 @@ except Exception:
10
10
  __version__ = "unknown"
11
11
 
12
12
  REGISTRIES = ("npm", "pypi", "go")
13
- COMMANDS = ("release", "status", "scaffold", "check", "config", "undo")
13
+ COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover")
14
14
  COMMAND_ALIASES = {"init": "scaffold"}
15
15
 
16
16
  HELP = f"""\
@@ -23,9 +23,11 @@ Usage:
23
23
  rlsbl check <name> Check name availability
24
24
  rlsbl config Show project configuration
25
25
  rlsbl undo [--yes] Revert the last release
26
+ rlsbl discover [--mine] List rlsbl ecosystem projects
26
27
 
27
28
  Options:
28
29
  --registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
30
+ --no-tag Disable ecosystem tagging for this invocation
29
31
  --help, -h Show this help
30
32
  --version, -v Show version"""
31
33
 
@@ -86,6 +88,7 @@ def _get_command_module(command):
86
88
  "check": "check",
87
89
  "config": "config",
88
90
  "undo": "undo",
91
+ "discover": "discover",
89
92
  }
90
93
  module_name = module_map.get(command)
91
94
  if not module_name:
@@ -176,6 +179,9 @@ def main():
176
179
  sys.exit(1)
177
180
  registry = regs[0]
178
181
  handler.run_cmd(registry, args, flags)
182
+ elif command == "discover":
183
+ # discover: global query, no registry needed
184
+ handler.run_cmd(registry, args, flags)
179
185
  else:
180
186
  # release, status: use explicit registry or auto-detect primary
181
187
  if not registry:
@@ -1,6 +1,7 @@
1
1
  """Config command: show resolved project configuration."""
2
2
 
3
3
  import os
4
+ from ..config import PROJECT_CONFIG, USER_CONFIG, read_json_config, should_tag
4
5
  from ..registries import REGISTRIES
5
6
 
6
7
 
@@ -41,9 +42,26 @@ def run_cmd(registry, args, flags):
41
42
  print("\nHooks:")
42
43
  pre_release = os.path.join("scripts", "pre-release.sh")
43
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")
46
+ print(f" post-release.sh: {'yes' if os.path.exists(post_release) else 'no'}")
44
47
  pre_push = os.path.join(".git", "hooks", "pre-push")
45
48
  print(f" pre-push hook: {'installed' if os.path.exists(pre_push) else 'not installed'}")
46
49
 
50
+ print("\nEcosystem tagging:")
51
+ enabled = should_tag(flags)
52
+ # Determine why it's enabled/disabled
53
+ project_cfg = read_json_config(PROJECT_CONFIG)
54
+ user_cfg = read_json_config(USER_CONFIG)
55
+ if flags.get("no-tag"):
56
+ source = "CLI flag"
57
+ elif "tag" in project_cfg:
58
+ source = f"project config ({PROJECT_CONFIG})"
59
+ elif "tag" in user_cfg:
60
+ source = f"user config ({USER_CONFIG})"
61
+ else:
62
+ source = "default"
63
+ print(f" Status: {'enabled' if enabled else 'disabled'} ({source})")
64
+
47
65
  print("\nFiles:")
48
66
  for f in ["CHANGELOG.md", "LICENSE", ".gitignore", "CLAUDE.md"]:
49
67
  print(f" {f}: {'yes' if os.path.exists(f) else 'no'}")
@@ -0,0 +1,194 @@
1
+ """Discover command: list projects in the rlsbl ecosystem."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+
11
+ SEARCH_URL = "https://api.github.com/search/repositories?q=topic:rlsbl&sort=updated&per_page=100"
12
+ MAX_RESULTS = 1000
13
+
14
+
15
+ def _get_github_token():
16
+ """Get a GitHub token from GITHUB_TOKEN env or `gh auth token`."""
17
+ token = os.environ.get("GITHUB_TOKEN")
18
+ if token:
19
+ return token
20
+ try:
21
+ result = subprocess.run(
22
+ ["gh", "auth", "token"],
23
+ capture_output=True, text=True, check=True, timeout=10,
24
+ )
25
+ return result.stdout.strip() or None
26
+ except Exception:
27
+ return None
28
+
29
+
30
+ def _make_request(url, token):
31
+ """Make a GET request to the GitHub API, return parsed JSON and response headers."""
32
+ req = urllib.request.Request(url, method="GET")
33
+ req.add_header("Accept", "application/vnd.github+json")
34
+ req.add_header("User-Agent", "rlsbl-cli")
35
+ if token:
36
+ req.add_header("Authorization", f"token {token}")
37
+ with urllib.request.urlopen(req, timeout=15) as resp:
38
+ data = json.loads(resp.read().decode("utf-8"))
39
+ headers = dict(resp.headers)
40
+ return data, headers
41
+
42
+
43
+ def _parse_next_link(headers):
44
+ """Extract the 'next' URL from the Link header, or None."""
45
+ link = headers.get("Link") or headers.get("link")
46
+ if not link:
47
+ return None
48
+ for part in link.split(","):
49
+ if 'rel="next"' in part:
50
+ # Extract URL between < and >
51
+ start = part.index("<") + 1
52
+ end = part.index(">")
53
+ return part[start:end]
54
+ return None
55
+
56
+
57
+ def _relative_time(iso_timestamp):
58
+ """Convert an ISO 8601 timestamp to a relative time string like '2d ago'."""
59
+ from datetime import datetime, timezone
60
+
61
+ if not iso_timestamp:
62
+ return ""
63
+
64
+ # Parse ISO timestamp (GitHub uses Z suffix)
65
+ ts = iso_timestamp.replace("Z", "+00:00")
66
+ dt = datetime.fromisoformat(ts)
67
+ now = datetime.now(timezone.utc)
68
+ delta = now - dt
69
+
70
+ seconds = int(delta.total_seconds())
71
+ if seconds < 60:
72
+ return "just now"
73
+ minutes = seconds // 60
74
+ if minutes < 60:
75
+ return f"{minutes}m ago"
76
+ hours = minutes // 60
77
+ if hours < 24:
78
+ return f"{hours}h ago"
79
+ days = hours // 24
80
+ if days < 7:
81
+ return f"{days}d ago"
82
+ weeks = days // 7
83
+ if weeks < 5:
84
+ return f"{weeks}w ago"
85
+ months = days // 30
86
+ if months < 12:
87
+ return f"{months}mo ago"
88
+ years = days // 365
89
+ return f"{years}y ago"
90
+
91
+
92
+ def _get_authenticated_user(token):
93
+ """Get the authenticated user's login name."""
94
+ if not token:
95
+ return None
96
+ try:
97
+ req = urllib.request.Request("https://api.github.com/user", method="GET")
98
+ req.add_header("Accept", "application/vnd.github+json")
99
+ req.add_header("User-Agent", "rlsbl-cli")
100
+ req.add_header("Authorization", f"token {token}")
101
+ with urllib.request.urlopen(req, timeout=10) as resp:
102
+ data = json.loads(resp.read().decode("utf-8"))
103
+ return data.get("login")
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ def _fetch_all_repos(token):
109
+ """Fetch all repos with the rlsbl topic, handling pagination."""
110
+ repos = []
111
+ url = SEARCH_URL
112
+
113
+ while url and len(repos) < MAX_RESULTS:
114
+ data, headers = _make_request(url, token)
115
+ items = data.get("items", [])
116
+ repos.extend(items)
117
+ url = _parse_next_link(headers)
118
+
119
+ return repos
120
+
121
+
122
+ def run_cmd(registry, args, flags):
123
+ """Discover command: list projects in the rlsbl ecosystem."""
124
+ token = _get_github_token()
125
+ mine_only = flags.get("mine", False)
126
+
127
+ if mine_only and not token:
128
+ print("Error: --mine requires authentication (set GITHUB_TOKEN or install gh CLI).", file=sys.stderr)
129
+ sys.exit(1)
130
+
131
+ # Fetch repos
132
+ try:
133
+ repos = _fetch_all_repos(token)
134
+ except urllib.error.HTTPError as e:
135
+ print(f"Error: GitHub API returned {e.code}: {e.reason}", file=sys.stderr)
136
+ sys.exit(1)
137
+ except urllib.error.URLError as e:
138
+ print(f"Error: could not reach GitHub API: {e.reason}", file=sys.stderr)
139
+ sys.exit(1)
140
+ except Exception as e:
141
+ print(f"Error: {e}", file=sys.stderr)
142
+ sys.exit(1)
143
+
144
+ # Filter to --mine if requested
145
+ if mine_only:
146
+ username = _get_authenticated_user(token)
147
+ if not username:
148
+ print("Error: could not determine authenticated user.", file=sys.stderr)
149
+ sys.exit(1)
150
+ repos = [r for r in repos if r.get("owner", {}).get("login") == username]
151
+
152
+ if not repos:
153
+ if mine_only:
154
+ print("No rlsbl-tagged repositories found for your account.")
155
+ else:
156
+ print("No rlsbl-tagged repositories found.")
157
+ return
158
+
159
+ # Build table rows
160
+ rows = []
161
+ for repo in repos:
162
+ full_name = repo.get("full_name", "")
163
+ description = repo.get("description") or ""
164
+ updated = _relative_time(repo.get("updated_at", ""))
165
+ rows.append((full_name, description, updated))
166
+
167
+ # Calculate column widths
168
+ name_width = max(len(r[0]) for r in rows)
169
+ desc_width = max(len(r[1]) for r in rows)
170
+ time_width = max(len(r[2]) for r in rows)
171
+
172
+ # Cap description width to keep output readable
173
+ max_desc = 40
174
+ if desc_width > max_desc:
175
+ desc_width = max_desc
176
+
177
+ # Ensure minimum widths match headers
178
+ name_width = max(name_width, len("owner/repo"))
179
+ desc_width = max(desc_width, len("description"))
180
+ time_width = max(time_width, len("updated"))
181
+
182
+ # Print header
183
+ print(f"\nrlsbl ecosystem ({len(repos)} projects)\n")
184
+ header = f" {'owner/repo':<{name_width}} {'description':<{desc_width}} {'updated':<{time_width}}"
185
+ print(header)
186
+ separator_len = name_width + desc_width + time_width + 6
187
+ print(f" {'─' * separator_len}")
188
+
189
+ # Print rows
190
+ for full_name, description, updated in rows:
191
+ # Truncate long descriptions
192
+ if len(description) > max_desc:
193
+ description = description[:max_desc - 1] + "…"
194
+ print(f" {full_name:<{name_width}} {description:<{desc_width}} {updated}")
@@ -8,7 +8,9 @@ import shutil
8
8
  import stat
9
9
  import sys
10
10
 
11
+ from ..config import should_tag
11
12
  from ..registries import REGISTRIES
13
+ from ..tagging import ensure_tags
12
14
 
13
15
  HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
14
16
 
@@ -24,6 +26,7 @@ UPDATABLE = {
24
26
  ".github/workflows/ci.yml",
25
27
  ".github/workflows/publish.yml",
26
28
  "scripts/check-prs.sh",
29
+ "scripts/post-release.sh",
27
30
  "scripts/pre-push-hook.sh",
28
31
  }
29
32
 
@@ -214,11 +217,17 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
214
217
 
215
218
 
216
219
  def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnings,
217
- registry=None):
218
- """Shared post-processing for scaffold: chmod, hooks, version marker, hashes, summary.
220
+ registry=None, flags=None, registries=None):
221
+ """Shared post-processing for scaffold: chmod, hooks, version marker, hashes, tagging, summary.
219
222
 
220
223
  all_hash_dicts is a list of dicts to merge into existing_hashes.
224
+ flags is the CLI flags dict (used for tagging check).
225
+ registries is a list of registry names (used for tagging).
221
226
  """
227
+ if flags is None:
228
+ flags = {}
229
+ if registries is None:
230
+ registries = [registry] if registry else []
222
231
  # Make all shell scripts in scripts/ executable
223
232
  scripts_dir = os.path.join(".", "scripts")
224
233
  if os.path.isdir(scripts_dir):
@@ -252,6 +261,10 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
252
261
  existing_hashes.update(all_new_hashes)
253
262
  save_hashes(existing_hashes)
254
263
 
264
+ # Ecosystem tagging
265
+ if should_tag(flags):
266
+ ensure_tags(registries)
267
+
255
268
  # Print summary
256
269
  if created:
257
270
  print("Created:")
@@ -335,6 +348,7 @@ def run_cmd(registry, args, flags):
335
348
  _finalize_scaffold(
336
349
  existing_hashes, [reg_hashes, shared_hashes],
337
350
  created, skipped, warnings, registry=registry,
351
+ flags=flags, registries=[registry],
338
352
  )
339
353
 
340
354
 
@@ -402,6 +416,7 @@ def run_cmd_multi(registries_list, args, flags):
402
416
  _finalize_scaffold(
403
417
  existing_hashes, [ci_hashes, merged_hashes, shared_hashes],
404
418
  created, skipped, warnings,
419
+ flags=flags, registries=registries_list,
405
420
  )
406
421
 
407
422
  # Show combined next steps for dual-registry
@@ -4,7 +4,9 @@ import os
4
4
  import sys
5
5
  import time
6
6
 
7
+ from ..config import should_tag
7
8
  from ..registries import REGISTRIES
9
+ from ..tagging import ensure_github_topic, ensure_npm_keyword, ensure_pypi_keyword
8
10
  from ..utils import (
9
11
  bump_version,
10
12
  check_gh_auth,
@@ -12,9 +14,11 @@ from ..utils import (
12
14
  extract_changelog_entry,
13
15
  find_commit_tool,
14
16
  get_current_branch,
17
+ get_push_timeout,
15
18
  is_clean_tree,
16
19
  push_if_needed,
17
20
  run,
21
+ spawn_ci_watcher,
18
22
  )
19
23
 
20
24
  VALID_BUMP_TYPES = ("patch", "minor", "major")
@@ -190,8 +194,26 @@ def run_cmd(registry, args, flags):
190
194
  other_reg.write_version(".", new_version)
191
195
  log(f"Synced version to {other_file}")
192
196
 
193
- # Commit version file changes (skip if version didn't change, e.g. first release)
194
- if files_to_commit and new_version != current_version:
197
+ # Ecosystem tagging: add keyword to manifests if enabled (after confirmation)
198
+ if should_tag(flags):
199
+ try:
200
+ if REGISTRIES["npm"].check_project_exists("."):
201
+ if ensure_npm_keyword(".", quiet=quiet):
202
+ if "package.json" not in files_to_commit:
203
+ files_to_commit.append("package.json")
204
+ except Exception:
205
+ pass
206
+ try:
207
+ if REGISTRIES["pypi"].check_project_exists("."):
208
+ if ensure_pypi_keyword(".", quiet=quiet):
209
+ if "pyproject.toml" not in files_to_commit:
210
+ files_to_commit.append("pyproject.toml")
211
+ except Exception:
212
+ pass
213
+
214
+ # Commit if anything was actually modified (version bump or tagging)
215
+ needs_commit = new_version != current_version or not is_clean_tree()
216
+ if files_to_commit and needs_commit:
195
217
  commit_tool = find_commit_tool()
196
218
  if commit_tool == "safegit":
197
219
  run(commit_tool, ["commit", "-m", tag, "--", *files_to_commit])
@@ -199,16 +221,19 @@ def run_cmd(registry, args, flags):
199
221
  run("git", ["add", *files_to_commit])
200
222
  run("git", ["commit", "-m", tag])
201
223
  log(f"Committed: {tag}")
202
- else:
203
- log("No version bump to commit")
224
+ elif not needs_commit:
225
+ log("No changes to commit")
204
226
 
205
227
  # Create local git tag
206
228
  run("git", ["tag", tag])
207
229
  log(f"Tagged: {tag}")
208
230
 
209
231
  # Push commits and tag
232
+ push_timeout = get_push_timeout()
233
+ if push_timeout != 120:
234
+ log(f"Push timeout: {push_timeout}s (from RLSBL_PUSH_TIMEOUT)")
210
235
  push_if_needed(branch)
211
- run("git", ["push", "origin", tag])
236
+ run("git", ["push", "origin", tag], timeout=push_timeout)
212
237
  log(f"Pushed to origin/{branch}")
213
238
 
214
239
  # Create GitHub Release using a temp notes file
@@ -226,4 +251,27 @@ def run_cmd(registry, args, flags):
226
251
  if os.path.exists(tmp):
227
252
  os.unlink(tmp)
228
253
 
254
+ # Ecosystem tagging: add GitHub topic after release is created
255
+ if should_tag(flags):
256
+ ensure_github_topic(quiet=quiet)
257
+
258
+ # Run post-release hook if present (non-fatal: release is already complete)
259
+ post_release_script = os.path.join(".", "scripts", "post-release.sh")
260
+ if os.path.exists(post_release_script):
261
+ log("Running post-release hook...")
262
+ try:
263
+ env = os.environ.copy()
264
+ env["RLSBL_VERSION"] = new_version
265
+ run("bash", [post_release_script], env=env)
266
+ except Exception as e:
267
+ print(f"Warning: post-release hook failed: {e}", file=sys.stderr)
268
+
269
+ # Watch CI in the background and notify on completion
270
+ try:
271
+ commit_sha = run("git", ["rev-parse", "HEAD"])
272
+ spawn_ci_watcher(commit_sha, tag)
273
+ log("Watching CI in background (will notify when done)")
274
+ except Exception:
275
+ pass
276
+
229
277
  log(f"\nRelease {new_version} complete!")
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- from ..utils import run, run_silent, check_gh_installed, check_gh_auth
5
+ from ..utils import run, check_gh_installed, check_gh_auth, get_push_timeout
6
6
 
7
7
 
8
8
  def run_cmd(registry, args, flags):
@@ -40,7 +40,7 @@ def run_cmd(registry, args, flags):
40
40
 
41
41
  # Delete remote tag
42
42
  try:
43
- run("git", ["push", "origin", f":{tag}"])
43
+ run("git", ["push", "origin", f":{tag}"], timeout=get_push_timeout())
44
44
  print(f"Deleted remote tag: {tag}")
45
45
  except Exception as e:
46
46
  print(f"Warning: could not delete remote tag: {e}")
@@ -0,0 +1,54 @@
1
+ """Config reading for the tag feature (ecosystem discoverability).
2
+
3
+ Precedence (highest to lowest):
4
+ 1. CLI flag (--no-tag)
5
+ 2. Project-level: .rlsbl/config.json
6
+ 3. User-level: ~/.rlsbl/config.json
7
+ 4. Default: True (tagging enabled)
8
+ """
9
+
10
+ import json
11
+ import os
12
+
13
+
14
+ PROJECT_CONFIG = os.path.join(".rlsbl", "config.json")
15
+ USER_CONFIG = os.path.expanduser("~/.rlsbl/config.json")
16
+
17
+
18
+ def read_json_config(path):
19
+ """Safely read a JSON file, returning {} on missing or malformed."""
20
+ try:
21
+ with open(path, "r", encoding="utf-8") as f:
22
+ return json.load(f)
23
+ except (OSError, json.JSONDecodeError):
24
+ return {}
25
+
26
+
27
+ def should_tag(flags):
28
+ """Returns True if tagging is enabled, checking flag > project > user > default."""
29
+ # CLI flag takes highest precedence
30
+ if flags.get("no-tag"):
31
+ return False
32
+
33
+ # Project-level config
34
+ project = read_json_config(PROJECT_CONFIG)
35
+ if "tag" in project:
36
+ return bool(project["tag"])
37
+
38
+ # User-level config
39
+ user = read_json_config(USER_CONFIG)
40
+ if "tag" in user:
41
+ return bool(user["tag"])
42
+
43
+ # Default: tagging enabled
44
+ return True
45
+
46
+
47
+ def write_project_config(key, value):
48
+ """Write or update a key in .rlsbl/config.json (creates dir if needed)."""
49
+ os.makedirs(os.path.dirname(PROJECT_CONFIG), exist_ok=True)
50
+ existing = read_json_config(PROJECT_CONFIG)
51
+ existing[key] = value
52
+ with open(PROJECT_CONFIG, "w", encoding="utf-8") as f:
53
+ json.dump(existing, f, indent=2)
54
+ f.write("\n")
@@ -110,6 +110,7 @@ def get_shared_template_mappings():
110
110
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
111
111
  {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
112
112
  {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
113
+ {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
113
114
  {"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
114
115
  ]
115
116
 
@@ -105,6 +105,7 @@ def get_shared_template_mappings():
105
105
  {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
106
106
  {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
107
107
  {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
108
+ {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
108
109
  {"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
109
110
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
110
111
  ]
@@ -157,6 +157,7 @@ def get_shared_template_mappings():
157
157
  {"template": "check-prs.sh.tpl", "target": "scripts/check-prs.sh"},
158
158
  {"template": "record-gif.sh.tpl", "target": "scripts/record-gif.sh"},
159
159
  {"template": "pre-release.sh.tpl", "target": "scripts/pre-release.sh"},
160
+ {"template": "post-release.sh.tpl", "target": "scripts/post-release.sh"},
160
161
  {"template": "pre-push-hook.sh.tpl", "target": "scripts/pre-push-hook.sh"},
161
162
  {"template": "claude-settings.json.tpl", "target": ".claude/settings.json"},
162
163
  ]
@@ -0,0 +1,202 @@
1
+ """Tagging module: inject "rlsbl" keywords into manifests and GitHub topics."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import tomllib
8
+ import urllib.request
9
+ import urllib.error
10
+
11
+ from .utils import run
12
+
13
+
14
+ def ensure_npm_keyword(dir_path=".", quiet=False):
15
+ """Add "rlsbl" to the keywords array in package.json if not already present."""
16
+ pkg_path = os.path.join(dir_path, "package.json")
17
+ with open(pkg_path, "r", encoding="utf-8") as f:
18
+ raw = f.read()
19
+
20
+ # Detect indent: look for the first indented line
21
+ indent_match = re.search(r'^( +|\t+)"', raw, re.MULTILINE)
22
+ indent = indent_match.group(1) if indent_match else " "
23
+
24
+ pkg = json.loads(raw)
25
+ keywords = pkg.get("keywords", [])
26
+
27
+ if "rlsbl" in keywords:
28
+ return False
29
+
30
+ keywords.append("rlsbl")
31
+ pkg["keywords"] = keywords
32
+
33
+ # Preserve trailing newline if present
34
+ trailing_newline = "\n" if raw.endswith("\n") else ""
35
+ output = json.dumps(pkg, indent=indent) + trailing_newline
36
+
37
+ # Atomic write: write to temp file, then rename
38
+ tmp_path = pkg_path + ".tmp"
39
+ with open(tmp_path, "w", encoding="utf-8") as f:
40
+ f.write(output)
41
+ os.replace(tmp_path, pkg_path)
42
+
43
+ if not quiet:
44
+ print('Tagged package.json with "rlsbl" keyword')
45
+ return True
46
+
47
+
48
+ def ensure_pypi_keyword(dir_path=".", quiet=False):
49
+ """Add "rlsbl" to the keywords array in pyproject.toml if not already present."""
50
+ toml_path = os.path.join(dir_path, "pyproject.toml")
51
+ with open(toml_path, "rb") as f:
52
+ data = tomllib.load(f)
53
+
54
+ # Check if already tagged
55
+ existing = data.get("project", {}).get("keywords", [])
56
+ if "rlsbl" in existing:
57
+ return False
58
+
59
+ # Read as text for regex-based editing
60
+ with open(toml_path, "r", encoding="utf-8") as f:
61
+ content = f.read()
62
+
63
+ # Find [project] section boundaries
64
+ project_match = re.search(r"^\[project\]\s*$", content, re.MULTILINE)
65
+ if not project_match:
66
+ raise ValueError("No [project] section found in pyproject.toml")
67
+
68
+ section_start = project_match.end()
69
+ next_section = re.search(r"^\[", content[section_start:], re.MULTILINE)
70
+ section_end = section_start + next_section.start() if next_section else len(content)
71
+ section = content[section_start:section_end]
72
+
73
+ # Case 1: keywords field already exists -- add "rlsbl" to the array
74
+ # Use DOTALL to handle multi-line arrays (e.g. keywords = [\n "foo",\n])
75
+ keywords_match = re.search(r'^(keywords\s*=\s*\[)(.*?)\]', section, re.MULTILINE | re.DOTALL)
76
+ if keywords_match:
77
+ prefix = keywords_match.group(1)
78
+ array_content = keywords_match.group(2)
79
+ # Detect if multi-line (contains newline between brackets)
80
+ if "\n" in array_content:
81
+ # Multi-line: insert before the closing bracket on its own line
82
+ # Find the indent used for existing items
83
+ item_indent_match = re.search(r'\n( +)"', array_content)
84
+ item_indent = item_indent_match.group(1) if item_indent_match else " "
85
+ new_array_content = array_content.rstrip() + f',\n{item_indent}"rlsbl"\n'
86
+ else:
87
+ # Single-line
88
+ if array_content.strip():
89
+ new_array_content = array_content.rstrip() + ', "rlsbl"'
90
+ else:
91
+ new_array_content = '"rlsbl"'
92
+ new_field = prefix + new_array_content + "]"
93
+ updated_section = section[:keywords_match.start()] + new_field + section[keywords_match.end():]
94
+ else:
95
+ # Case 2: keywords field missing -- insert after the version line
96
+ version_match = re.search(r'^version\s*=\s*"[^"]*"\s*$', section, re.MULTILINE)
97
+ if version_match:
98
+ insert_pos = version_match.end()
99
+ else:
100
+ # Fallback: insert at the beginning of the section
101
+ insert_pos = 0
102
+ updated_section = (
103
+ section[:insert_pos] + '\nkeywords = ["rlsbl"]' + section[insert_pos:]
104
+ )
105
+
106
+ updated = content[:section_start] + updated_section + content[section_end:]
107
+
108
+ # Atomic write: write to temp file, then rename
109
+ tmp_path = toml_path + ".tmp"
110
+ with open(tmp_path, "w", encoding="utf-8") as f:
111
+ f.write(updated)
112
+ os.replace(tmp_path, toml_path)
113
+
114
+ if not quiet:
115
+ print('Tagged pyproject.toml with "rlsbl" keyword')
116
+ return True
117
+
118
+
119
+ def ensure_github_topic(quiet=False):
120
+ """Add "rlsbl" topic to the GitHub repository if not already present."""
121
+ # Try to get a GitHub token (env var first, then gh CLI)
122
+ token = os.environ.get("GITHUB_TOKEN")
123
+ if not token:
124
+ try:
125
+ token = run("gh", ["auth", "token"])
126
+ except (subprocess.CalledProcessError, FileNotFoundError):
127
+ pass
128
+
129
+ if not token:
130
+ if not quiet:
131
+ print("No GitHub token available. Run 'gh auth login' or set GITHUB_TOKEN.")
132
+ return False
133
+
134
+ # Detect repo name
135
+ repo_name = None
136
+ try:
137
+ repo_name = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
138
+ except (subprocess.CalledProcessError, FileNotFoundError):
139
+ pass
140
+
141
+ if not repo_name:
142
+ # Fallback: parse from git remote
143
+ try:
144
+ remote_url = run("git", ["remote", "get-url", "origin"])
145
+ match = re.search(r"github\.com[/:]([^/]+/[^/.]+)", remote_url)
146
+ if match:
147
+ repo_name = match.group(1).removesuffix(".git")
148
+ except (subprocess.CalledProcessError, FileNotFoundError):
149
+ pass
150
+
151
+ if not repo_name:
152
+ if not quiet:
153
+ print("Warning: could not detect GitHub repository name.")
154
+ return False
155
+
156
+ owner, repo = repo_name.split("/", 1)
157
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/topics"
158
+ headers = {
159
+ "Authorization": f"token {token}",
160
+ "Accept": "application/vnd.github+json",
161
+ "User-Agent": "rlsbl-cli",
162
+ }
163
+
164
+ # GET existing topics
165
+ try:
166
+ req = urllib.request.Request(api_url, headers=headers)
167
+ with urllib.request.urlopen(req, timeout=15) as resp:
168
+ data = json.loads(resp.read().decode("utf-8"))
169
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
170
+ if not quiet:
171
+ print(f"Warning: failed to fetch GitHub topics: {e}")
172
+ return False
173
+
174
+ topics = data.get("names", [])
175
+ if "rlsbl" in topics:
176
+ return False
177
+
178
+ # PUT with merged topics list
179
+ topics.append("rlsbl")
180
+ payload = json.dumps({"names": topics}).encode("utf-8")
181
+ try:
182
+ req = urllib.request.Request(api_url, data=payload, headers=headers, method="PUT")
183
+ req.add_header("Content-Type", "application/json")
184
+ with urllib.request.urlopen(req, timeout=15) as resp:
185
+ resp.read() # consume response
186
+ except (urllib.error.URLError, OSError) as e:
187
+ if not quiet:
188
+ print(f"Warning: failed to set GitHub topics: {e}")
189
+ return False
190
+
191
+ if not quiet:
192
+ print('Added "rlsbl" topic to GitHub repository')
193
+ return True
194
+
195
+
196
+ def ensure_tags(registries, dir_path=".", quiet=False):
197
+ """Tag manifests and GitHub repo based on detected registries."""
198
+ if "npm" in registries:
199
+ ensure_npm_keyword(dir_path, quiet=quiet)
200
+ if "pypi" in registries:
201
+ ensure_pypi_keyword(dir_path, quiet=quiet)
202
+ ensure_github_topic(quiet=quiet)
package/rlsbl/utils.py CHANGED
@@ -7,21 +7,13 @@ import subprocess
7
7
  import sys
8
8
 
9
9
 
10
- def run(cmd, args=None, timeout=30):
10
+ def run(cmd, args=None, timeout=120, env=None):
11
11
  """Run a command with args, return trimmed stdout. Raise on failure."""
12
12
  full_cmd = [cmd] + (args or [])
13
- result = subprocess.run(full_cmd, capture_output=True, text=True, check=True, timeout=timeout)
13
+ result = subprocess.run(full_cmd, capture_output=True, text=True, check=True, timeout=timeout, env=env)
14
14
  return result.stdout.strip()
15
15
 
16
16
 
17
- def run_silent(cmd, args=None, timeout=30):
18
- """Run a command suppressing stderr. Return trimmed stdout. Raise on failure."""
19
- full_cmd = [cmd] + (args or [])
20
- result = subprocess.run(
21
- full_cmd, capture_output=True, text=True, check=True, timeout=timeout,
22
- )
23
- return result.stdout.strip()
24
-
25
17
 
26
18
  def is_clean_tree():
27
19
  """Returns True if the git working tree is clean (no uncommitted changes)."""
@@ -34,18 +26,34 @@ def get_current_branch():
34
26
  return run("git", ["rev-parse", "--abbrev-ref", "HEAD"])
35
27
 
36
28
 
29
+ def get_push_timeout():
30
+ """Return the push timeout in seconds, from RLSBL_PUSH_TIMEOUT or default 120."""
31
+ raw = os.environ.get("RLSBL_PUSH_TIMEOUT")
32
+ if raw is None:
33
+ return 120
34
+ try:
35
+ val = int(raw)
36
+ if val <= 0:
37
+ raise ValueError
38
+ return val
39
+ except ValueError:
40
+ print(f'Warning: invalid RLSBL_PUSH_TIMEOUT="{raw}", using default 120s', file=sys.stderr)
41
+ return 120
42
+
43
+
37
44
  def push_if_needed(branch):
38
45
  """Push the branch to origin if local is ahead of remote."""
46
+ timeout = get_push_timeout()
39
47
  local = run("git", ["rev-parse", branch])
40
48
  try:
41
49
  remote = run("git", ["rev-parse", f"origin/{branch}"])
42
50
  except subprocess.CalledProcessError:
43
51
  # Remote branch doesn't exist yet; push it
44
- run("git", ["push", "-u", "origin", branch])
52
+ run("git", ["push", "-u", "origin", branch], timeout=timeout)
45
53
  return
46
54
 
47
55
  if local != remote:
48
- run("git", ["push", "origin", branch])
56
+ run("git", ["push", "origin", branch], timeout=timeout)
49
57
 
50
58
 
51
59
  def extract_changelog_entry(changelog_path, version):
@@ -100,6 +108,79 @@ def find_commit_tool():
100
108
  return "git"
101
109
 
102
110
 
111
+ def spawn_ci_watcher(commit_sha, tag):
112
+ """Spawn a detached background process that watches CI and sends a desktop notification."""
113
+ repo_name = ""
114
+ try:
115
+ repo_name = run("gh", ["repo", "view", "--json", "name", "-q", ".name"])
116
+ except Exception:
117
+ pass
118
+
119
+ notify_cmd = _notify_command()
120
+ if not notify_cmd:
121
+ return
122
+
123
+ label = f"{repo_name} {tag}" if repo_name else tag
124
+
125
+ script = f"""
126
+ import subprocess, sys, time
127
+
128
+ run_id = None
129
+ for _ in range(15):
130
+ try:
131
+ r = subprocess.run(
132
+ ["gh", "run", "list", "--commit", "{commit_sha}", "--limit", "1",
133
+ "--json", "databaseId", "-q", ".[0].databaseId"],
134
+ capture_output=True, text=True, timeout=10)
135
+ if r.returncode == 0 and r.stdout.strip():
136
+ run_id = r.stdout.strip()
137
+ break
138
+ except Exception:
139
+ pass
140
+ time.sleep(2)
141
+
142
+ if not run_id:
143
+ sys.exit(0)
144
+
145
+ result = subprocess.run(
146
+ ["gh", "run", "watch", run_id, "--exit-status"],
147
+ capture_output=True, text=True, timeout=3600)
148
+
149
+ summary = ""
150
+ for line in reversed(result.stdout.strip().splitlines()):
151
+ if line.strip():
152
+ summary = line.strip()
153
+ break
154
+
155
+ ok = result.returncode == 0
156
+ title = "{label}: CI passed" if ok else "{label}: CI FAILED"
157
+ {notify_cmd}
158
+ """
159
+ subprocess.Popen(
160
+ [sys.executable, "-c", script],
161
+ start_new_session=True,
162
+ stdin=subprocess.DEVNULL,
163
+ stdout=subprocess.DEVNULL,
164
+ stderr=subprocess.DEVNULL,
165
+ )
166
+
167
+
168
+ def _notify_command():
169
+ """Return a Python code snippet for sending a desktop notification, or None."""
170
+ if sys.platform == "darwin":
171
+ return (
172
+ 'subprocess.run(["osascript", "-e",'
173
+ ' f\'display notification "{summary}" with title "{title}"\'],'
174
+ ' timeout=5)'
175
+ )
176
+ if shutil.which("notify-send"):
177
+ return (
178
+ 'urgency = "normal" if ok else "critical"\n'
179
+ 'subprocess.run(["notify-send", "-u", urgency, title, summary], timeout=5)'
180
+ )
181
+ return None
182
+
183
+
103
184
  def bump_version(version, bump_type):
104
185
  """Bump a semver version string by the given type (patch, minor, major).
105
186
 
@@ -11,3 +11,4 @@ dist/
11
11
  .*-cache.json
12
12
  .env
13
13
  .env.local
14
+ *.local-only
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ # Post-release hook. Runs after a successful release (non-fatal).
3
+ # Environment: RLSBL_VERSION is set to the released version.
4
+ # Customize this for your project (e.g., local install, deploy, notify).
5
+
6
+ set -euo pipefail
7
+
8
+ echo "Post-release: v$RLSBL_VERSION"