rlsbl 0.4.2 → 0.5.1
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 +32 -1
- 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 +48 -4
- 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 +108 -2
- package/templates/shared/gitignore.tpl +1 -0
- package/templates/shared/post-release.sh.tpl +8 -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 `scripts/pre-release.sh` exists, it runs before any changes are made. A non-zero exit aborts the release.
|
|
65
|
+
If `scripts/pre-release.sh` exists, it runs before any changes are made. A non-zero exit aborts the release. If `scripts/post-release.sh` exists, it runs after the release completes (non-fatal). See [Release flow](#release-flow) for details.
|
|
66
66
|
|
|
67
67
|
### status
|
|
68
68
|
|
|
@@ -84,6 +84,31 @@ rlsbl check my-cool-lib --registry npm # npm only
|
|
|
84
84
|
|
|
85
85
|
npm checks variant spellings (hyphens, underscores, dots, no separator). PyPI normalizes per PEP 503 and checks common alternatives.
|
|
86
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
|
+
|
|
87
112
|
Global flags: `--help`, `--version`.
|
|
88
113
|
|
|
89
114
|
## Release flow
|
|
@@ -102,6 +127,8 @@ When you run `release`, the following happens in order:
|
|
|
102
127
|
10. Pushes the branch to `origin`
|
|
103
128
|
11. Creates a GitHub Release tagged `v<new-version>` with the changelog entry as notes
|
|
104
129
|
12. The GitHub Release triggers `publish.yml`, which publishes to the registry
|
|
130
|
+
13. Runs `scripts/post-release.sh` if present (non-fatal -- the release is already complete). The `RLSBL_VERSION` env var is set to the released version. Useful for local install (`go install ./cmd/myapp/`), deploy, or notifications.
|
|
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.
|
|
105
132
|
|
|
106
133
|
## What scaffold creates
|
|
107
134
|
|
|
@@ -116,11 +143,14 @@ When you run `release`, the following happens in order:
|
|
|
116
143
|
| `.claude/settings.json` | Shared | Claude Code settings |
|
|
117
144
|
| `scripts/check-prs.sh` | Shared | PR review helper |
|
|
118
145
|
| `scripts/pre-release.sh` | Shared | Pre-release hook (runs before each release) |
|
|
146
|
+
| `scripts/post-release.sh` | Shared | Post-release hook (runs after each release, non-fatal) |
|
|
119
147
|
| `scripts/record-gif.sh` | Shared | Terminal recording helper |
|
|
120
148
|
| `scripts/pre-push-hook.sh` | Shared | Pre-push changelog enforcement |
|
|
121
149
|
|
|
122
150
|
All `.sh` files in `scripts/` are made executable automatically. The pre-push hook is installed into `.git/hooks/pre-push` during scaffold.
|
|
123
151
|
|
|
152
|
+
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
|
+
|
|
124
154
|
## Pre-push hook
|
|
125
155
|
|
|
126
156
|
The scaffolded `scripts/pre-push-hook.sh` is installed as a git pre-push hook during `scaffold`. It prevents pushing when `CHANGELOG.md` lacks an entry for the current version.
|
|
@@ -155,6 +185,7 @@ After configuration, all subsequent releases are handled by CI when `rlsbl relea
|
|
|
155
185
|
| Variable | Default | Description |
|
|
156
186
|
|----------|---------|-------------|
|
|
157
187
|
| `RLSBL_PUSH_TIMEOUT` | `120` | Timeout in seconds for `git push` operations. Increase if your pre-push hooks (e.g. test suites) take longer than 2 minutes. |
|
|
188
|
+
| `RLSBL_VERSION` | -- | Set automatically when running `scripts/post-release.sh`. Contains the just-released version string. |
|
|
158
189
|
|
|
159
190
|
## Requirements
|
|
160
191
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rlsbl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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,
|
|
@@ -16,6 +18,7 @@ from ..utils import (
|
|
|
16
18
|
is_clean_tree,
|
|
17
19
|
push_if_needed,
|
|
18
20
|
run,
|
|
21
|
+
spawn_ci_watcher,
|
|
19
22
|
)
|
|
20
23
|
|
|
21
24
|
VALID_BUMP_TYPES = ("patch", "minor", "major")
|
|
@@ -191,8 +194,26 @@ def run_cmd(registry, args, flags):
|
|
|
191
194
|
other_reg.write_version(".", new_version)
|
|
192
195
|
log(f"Synced version to {other_file}")
|
|
193
196
|
|
|
194
|
-
#
|
|
195
|
-
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:
|
|
196
217
|
commit_tool = find_commit_tool()
|
|
197
218
|
if commit_tool == "safegit":
|
|
198
219
|
run(commit_tool, ["commit", "-m", tag, "--", *files_to_commit])
|
|
@@ -200,8 +221,8 @@ def run_cmd(registry, args, flags):
|
|
|
200
221
|
run("git", ["add", *files_to_commit])
|
|
201
222
|
run("git", ["commit", "-m", tag])
|
|
202
223
|
log(f"Committed: {tag}")
|
|
203
|
-
|
|
204
|
-
log("No
|
|
224
|
+
elif not needs_commit:
|
|
225
|
+
log("No changes to commit")
|
|
205
226
|
|
|
206
227
|
# Create local git tag
|
|
207
228
|
run("git", ["tag", tag])
|
|
@@ -230,4 +251,27 @@ def run_cmd(registry, args, flags):
|
|
|
230
251
|
if os.path.exists(tmp):
|
|
231
252
|
os.unlink(tmp)
|
|
232
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
|
+
|
|
233
277
|
log(f"\nRelease {new_version} complete!")
|
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,10 +7,10 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def run(cmd, args=None, timeout=120):
|
|
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
|
|
|
@@ -108,6 +108,112 @@ 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
|
+
|
|
111
217
|
def bump_version(version, bump_type):
|
|
112
218
|
"""Bump a semver version string by the given type (patch, minor, major).
|
|
113
219
|
|
|
@@ -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"
|