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 +35 -0
- package/package.json +3 -2
- package/rlsbl/__init__.py +7 -1
- package/rlsbl/commands/config.py +18 -0
- package/rlsbl/commands/discover.py +194 -0
- package/rlsbl/commands/init_cmd.py +17 -2
- package/rlsbl/commands/release.py +53 -5
- package/rlsbl/commands/undo.py +2 -2
- package/rlsbl/config.py +54 -0
- package/rlsbl/registries/go.py +1 -0
- package/rlsbl/registries/npm.py +1 -0
- package/rlsbl/registries/pypi.py +1 -0
- package/rlsbl/tagging.py +202 -0
- package/rlsbl/utils.py +93 -12
- package/templates/shared/gitignore.tpl +1 -0
- package/templates/shared/post-release.sh.tpl +8 -0
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.
|
|
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:
|
package/rlsbl/commands/config.py
CHANGED
|
@@ -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
|
-
#
|
|
194
|
-
if
|
|
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
|
-
|
|
203
|
-
log("No
|
|
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!")
|
package/rlsbl/commands/undo.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
-
from ..utils import run,
|
|
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}")
|
package/rlsbl/config.py
ADDED
|
@@ -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")
|
package/rlsbl/registries/go.py
CHANGED
|
@@ -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
|
|
package/rlsbl/registries/npm.py
CHANGED
|
@@ -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
|
]
|
package/rlsbl/registries/pypi.py
CHANGED
|
@@ -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
|
]
|
package/rlsbl/tagging.py
ADDED
|
@@ -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=
|
|
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
|
|
|
@@ -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"
|