rlsbl 0.5.0 → 0.5.2
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 +7 -1
- package/package.json +1 -1
- package/rlsbl/__init__.py +41 -9
- package/rlsbl/commands/check.py +16 -7
- package/rlsbl/commands/discover.py +2 -0
- package/rlsbl/commands/init_cmd.py +7 -1
- package/rlsbl/registries/go.py +1 -0
- package/rlsbl/registries/npm.py +1 -0
- package/rlsbl/registries/pypi.py +1 -0
- package/rlsbl/utils.py +52 -19
- package/templates/go/ci.yml.tpl +2 -2
- package/templates/go/publish.yml.tpl +4 -4
- package/templates/merged/publish.yml.tpl +3 -3
- package/templates/npm/ci.yml.tpl +2 -2
- package/templates/npm/publish.yml.tpl +2 -2
- package/templates/pypi/ci.yml.tpl +1 -1
- package/templates/pypi/publish.yml.tpl +1 -1
- package/templates/shared/CLAUDE.md.tpl +1 -1
- package/templates/shared/record-gif.sh.tpl +1 -1
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
|
|
|
@@ -127,6 +127,8 @@ When you run `release`, the following happens in order:
|
|
|
127
127
|
10. Pushes the branch to `origin`
|
|
128
128
|
11. Creates a GitHub Release tagged `v<new-version>` with the changelog entry as notes
|
|
129
129
|
12. The GitHub Release triggers `publish.yml`, which publishes to the registry
|
|
130
|
+
13. Runs `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.
|
|
130
132
|
|
|
131
133
|
## What scaffold creates
|
|
132
134
|
|
|
@@ -141,11 +143,14 @@ When you run `release`, the following happens in order:
|
|
|
141
143
|
| `.claude/settings.json` | Shared | Claude Code settings |
|
|
142
144
|
| `scripts/check-prs.sh` | Shared | PR review helper |
|
|
143
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) |
|
|
144
147
|
| `scripts/record-gif.sh` | Shared | Terminal recording helper |
|
|
145
148
|
| `scripts/pre-push-hook.sh` | Shared | Pre-push changelog enforcement |
|
|
146
149
|
|
|
147
150
|
All `.sh` files in `scripts/` are made executable automatically. The pre-push hook is installed into `.git/hooks/pre-push` during scaffold.
|
|
148
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
|
+
|
|
149
154
|
## Pre-push hook
|
|
150
155
|
|
|
151
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.
|
|
@@ -180,6 +185,7 @@ After configuration, all subsequent releases are handled by CI when `rlsbl relea
|
|
|
180
185
|
| Variable | Default | Description |
|
|
181
186
|
|----------|---------|-------------|
|
|
182
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. |
|
|
183
189
|
|
|
184
190
|
## Requirements
|
|
185
191
|
|
package/package.json
CHANGED
package/rlsbl/__init__.py
CHANGED
|
@@ -3,11 +3,40 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
|
|
7
|
+
def _detect_version():
|
|
8
|
+
"""Detect package version, preferring pyproject.toml over installed metadata.
|
|
9
|
+
|
|
10
|
+
Order: pyproject.toml in the source tree (accurate during editable installs)
|
|
11
|
+
-> importlib.metadata (works for regular installs) -> "unknown".
|
|
12
|
+
"""
|
|
13
|
+
# Try reading version from pyproject.toml next to the package source
|
|
14
|
+
try:
|
|
15
|
+
pyproject_path = os.path.realpath(
|
|
16
|
+
os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")
|
|
17
|
+
)
|
|
18
|
+
if os.path.isfile(pyproject_path):
|
|
19
|
+
try:
|
|
20
|
+
import tomllib
|
|
21
|
+
except ModuleNotFoundError:
|
|
22
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
23
|
+
with open(pyproject_path, "rb") as f:
|
|
24
|
+
data = tomllib.load(f)
|
|
25
|
+
return data["project"]["version"]
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Fall back to installed dist-info metadata
|
|
30
|
+
try:
|
|
31
|
+
from importlib.metadata import version as _get_version
|
|
32
|
+
return _get_version("rlsbl")
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
return "unknown"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__version__ = _detect_version()
|
|
11
40
|
|
|
12
41
|
REGISTRIES = ("npm", "pypi", "go")
|
|
13
42
|
COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover")
|
|
@@ -145,14 +174,17 @@ def main():
|
|
|
145
174
|
sys.exit(1)
|
|
146
175
|
|
|
147
176
|
if command == "check":
|
|
148
|
-
# check: if registry given, check that one; otherwise check
|
|
177
|
+
# check: if registry given, check that one; otherwise check npm and pypi.
|
|
178
|
+
# Go is excluded from the default set because Go modules use repository
|
|
179
|
+
# paths (e.g. github.com/user/repo), not a flat claimable namespace, so
|
|
180
|
+
# "available" would be misleading. Pass --registry go explicitly to check.
|
|
149
181
|
if registry:
|
|
150
182
|
handler.run_cmd(registry, args, flags)
|
|
151
183
|
else:
|
|
152
|
-
|
|
153
|
-
for i, r in enumerate(
|
|
184
|
+
default_registries = ["npm", "pypi"]
|
|
185
|
+
for i, r in enumerate(default_registries):
|
|
154
186
|
handler.run_cmd(r, args, flags)
|
|
155
|
-
if i < len(
|
|
187
|
+
if i < len(default_registries) - 1:
|
|
156
188
|
print("")
|
|
157
189
|
elif command == "scaffold":
|
|
158
190
|
if registry:
|
package/rlsbl/commands/check.py
CHANGED
|
@@ -168,35 +168,44 @@ def _check_name_pypi(name):
|
|
|
168
168
|
def check_go_availability(name):
|
|
169
169
|
"""Check if a Go module path exists on pkg.go.dev.
|
|
170
170
|
|
|
171
|
-
Returns {"status": "
|
|
171
|
+
Returns {"status": "not_found"|"exists"|"error", "message"?: str, "note"?: str}.
|
|
172
|
+
|
|
173
|
+
Go modules use repository paths (e.g. github.com/user/repo), not a flat
|
|
174
|
+
claimable namespace, so we report "not found" / "exists" rather than the
|
|
175
|
+
"available" / "taken" language used for npm and PyPI.
|
|
172
176
|
"""
|
|
173
177
|
url = f"https://pkg.go.dev/{name}"
|
|
174
178
|
try:
|
|
175
179
|
req = urllib.request.Request(url, method="GET")
|
|
176
180
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
177
181
|
if resp.status == 200:
|
|
178
|
-
return {"status": "
|
|
182
|
+
return {"status": "exists"}
|
|
179
183
|
return {"status": "error", "message": f"Unexpected status {resp.status}"}
|
|
180
184
|
except urllib.error.HTTPError as e:
|
|
181
185
|
if e.code == 404:
|
|
182
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
"status": "not_found",
|
|
188
|
+
"note": "Go modules use repository paths, not a central registry.",
|
|
189
|
+
}
|
|
183
190
|
return {"status": "error", "message": f"Unexpected status {e.code}"}
|
|
184
191
|
except Exception as e:
|
|
185
192
|
return {"status": "error", "message": str(e) or "Network error"}
|
|
186
193
|
|
|
187
194
|
|
|
188
195
|
def _check_name_go(name):
|
|
189
|
-
"""Check Go module path
|
|
196
|
+
"""Check Go module path on pkg.go.dev."""
|
|
190
197
|
print(f'Checking pkg.go.dev for "{name}"...')
|
|
191
198
|
|
|
192
199
|
result = check_go_availability(name)
|
|
193
200
|
if result["status"] == "error":
|
|
194
201
|
print(f"Error checking pkg.go.dev: {result['message']}", file=sys.stderr)
|
|
195
202
|
sys.exit(1)
|
|
196
|
-
if result["status"] == "
|
|
197
|
-
print(f'"{name}"
|
|
203
|
+
if result["status"] == "not_found":
|
|
204
|
+
print(f'"{name}" not found on pkg.go.dev.')
|
|
198
205
|
else:
|
|
199
|
-
print(f'"{name}"
|
|
206
|
+
print(f'"{name}" exists on pkg.go.dev.')
|
|
207
|
+
if result.get("note"):
|
|
208
|
+
print(f" Note: {result['note']}")
|
|
200
209
|
|
|
201
210
|
|
|
202
211
|
def run_cmd(registry, args, flags):
|
|
@@ -133,6 +133,8 @@ def run_cmd(registry, args, flags):
|
|
|
133
133
|
repos = _fetch_all_repos(token)
|
|
134
134
|
except urllib.error.HTTPError as e:
|
|
135
135
|
print(f"Error: GitHub API returned {e.code}: {e.reason}", file=sys.stderr)
|
|
136
|
+
if e.code == 403:
|
|
137
|
+
print("Hint: run 'gh auth login' to increase API rate limits (60/hr unauthenticated → 5000/hr).", file=sys.stderr)
|
|
136
138
|
sys.exit(1)
|
|
137
139
|
except urllib.error.URLError as e:
|
|
138
140
|
print(f"Error: could not reach GitHub API: {e.reason}", file=sys.stderr)
|
|
@@ -177,13 +177,19 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
177
177
|
existing_lines = {
|
|
178
178
|
line.strip() for line in existing.split("\n") if line.strip()
|
|
179
179
|
}
|
|
180
|
+
# Normalize by stripping trailing slashes so e.g.
|
|
181
|
+
# "*.egg-info/" matches "*.egg-info" and vice versa.
|
|
182
|
+
existing_normalized = {
|
|
183
|
+
line.rstrip("/") for line in existing_lines
|
|
184
|
+
}
|
|
180
185
|
new_lines = [
|
|
181
186
|
line.strip() for line in content.split("\n") if line.strip()
|
|
182
187
|
]
|
|
183
188
|
# Only merge non-comment entries that are missing from the existing file
|
|
184
189
|
missing = [
|
|
185
190
|
line for line in new_lines
|
|
186
|
-
if line not in
|
|
191
|
+
if line.rstrip("/") not in existing_normalized
|
|
192
|
+
and not line.startswith("#")
|
|
187
193
|
]
|
|
188
194
|
if missing:
|
|
189
195
|
with open(target, "a", encoding="utf-8") as f:
|
package/rlsbl/registries/go.py
CHANGED
package/rlsbl/registries/npm.py
CHANGED
package/rlsbl/registries/pypi.py
CHANGED
package/rlsbl/utils.py
CHANGED
|
@@ -109,27 +109,42 @@ def find_commit_tool():
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def spawn_ci_watcher(commit_sha, tag):
|
|
112
|
-
"""Spawn a detached background process that watches CI and
|
|
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
|
+
|
|
113
124
|
repo_name = ""
|
|
114
125
|
try:
|
|
115
126
|
repo_name = run("gh", ["repo", "view", "--json", "name", "-q", ".name"])
|
|
116
127
|
except Exception:
|
|
117
128
|
pass
|
|
118
129
|
|
|
119
|
-
notify_cmd = _notify_command()
|
|
120
|
-
if not notify_cmd:
|
|
121
|
-
return
|
|
122
|
-
|
|
123
130
|
label = f"{repo_name} {tag}" if repo_name else tag
|
|
124
131
|
|
|
132
|
+
# Build the notification snippet based on what's available on this platform
|
|
133
|
+
notify_snippet = _notify_snippet()
|
|
134
|
+
|
|
125
135
|
script = f"""
|
|
126
136
|
import subprocess, sys, time
|
|
127
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)
|
|
128
143
|
run_id = None
|
|
129
144
|
for _ in range(15):
|
|
130
145
|
try:
|
|
131
146
|
r = subprocess.run(
|
|
132
|
-
["gh", "run", "list", "--commit",
|
|
147
|
+
["gh", "run", "list", "--commit", commit_sha, "--limit", "1",
|
|
133
148
|
"--json", "databaseId", "-q", ".[0].databaseId"],
|
|
134
149
|
capture_output=True, text=True, timeout=10)
|
|
135
150
|
if r.returncode == 0 and r.stdout.strip():
|
|
@@ -142,43 +157,61 @@ for _ in range(15):
|
|
|
142
157
|
if not run_id:
|
|
143
158
|
sys.exit(0)
|
|
144
159
|
|
|
160
|
+
# Watch the run until it completes
|
|
145
161
|
result = subprocess.run(
|
|
146
162
|
["gh", "run", "watch", run_id, "--exit-status"],
|
|
147
163
|
capture_output=True, text=True, timeout=3600)
|
|
148
164
|
|
|
165
|
+
ok = result.returncode == 0
|
|
166
|
+
|
|
167
|
+
# Extract last non-empty line as summary for desktop notification
|
|
149
168
|
summary = ""
|
|
150
169
|
for line in reversed(result.stdout.strip().splitlines()):
|
|
151
170
|
if line.strip():
|
|
152
171
|
summary = line.strip()
|
|
153
172
|
break
|
|
154
173
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{
|
|
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
|
|
158
188
|
"""
|
|
159
189
|
subprocess.Popen(
|
|
160
190
|
[sys.executable, "-c", script],
|
|
161
191
|
start_new_session=True,
|
|
162
192
|
stdin=subprocess.DEVNULL,
|
|
163
|
-
stdout=subprocess.DEVNULL,
|
|
164
|
-
stderr=subprocess.DEVNULL,
|
|
165
193
|
)
|
|
166
194
|
|
|
167
195
|
|
|
168
|
-
def
|
|
169
|
-
"""Return
|
|
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 = " "
|
|
170
203
|
if sys.platform == "darwin":
|
|
171
204
|
return (
|
|
172
|
-
'subprocess.run(["osascript", "-e"
|
|
173
|
-
'
|
|
174
|
-
'
|
|
205
|
+
f'{indent}subprocess.run(["osascript", "-e",\n'
|
|
206
|
+
f'{indent} f\'display notification "{{summary}}" with title "{{title}}"\'],\n'
|
|
207
|
+
f'{indent} timeout=5)'
|
|
175
208
|
)
|
|
176
209
|
if shutil.which("notify-send"):
|
|
177
210
|
return (
|
|
178
|
-
'urgency = "normal" if ok else "critical"\n'
|
|
179
|
-
'subprocess.run(["notify-send", "-u", urgency, title, summary], timeout=5)'
|
|
211
|
+
f'{indent}urgency = "normal" if ok else "critical"\n'
|
|
212
|
+
f'{indent}subprocess.run(["notify-send", "-u", urgency, title, summary], timeout=5)'
|
|
180
213
|
)
|
|
181
|
-
return
|
|
214
|
+
return f"{indent}pass"
|
|
182
215
|
|
|
183
216
|
|
|
184
217
|
def bump_version(version, bump_type):
|
package/templates/go/ci.yml.tpl
CHANGED
|
@@ -13,8 +13,8 @@ jobs:
|
|
|
13
13
|
matrix:
|
|
14
14
|
go-version: ["1.22", "1.23", "1.24"]
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
17
|
-
- uses: actions/setup-go@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
|
+
- uses: actions/setup-go@v6
|
|
18
18
|
with:
|
|
19
19
|
go-version: ${{ matrix.go-version }}
|
|
20
20
|
- run: go test ./...
|
|
@@ -11,15 +11,15 @@ jobs:
|
|
|
11
11
|
goreleaser:
|
|
12
12
|
runs-on: ubuntu-latest
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
14
|
+
- uses: actions/checkout@v6
|
|
15
15
|
with:
|
|
16
16
|
fetch-depth: 0
|
|
17
|
-
- uses: actions/setup-go@
|
|
17
|
+
- uses: actions/setup-go@v6
|
|
18
18
|
with:
|
|
19
19
|
go-version-file: go.mod
|
|
20
|
-
- uses: goreleaser/goreleaser-action@
|
|
20
|
+
- uses: goreleaser/goreleaser-action@v7
|
|
21
21
|
with:
|
|
22
|
-
version:
|
|
22
|
+
version: "~> v2"
|
|
23
23
|
args: release --clean
|
|
24
24
|
env:
|
|
25
25
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -12,8 +12,8 @@ jobs:
|
|
|
12
12
|
npm:
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
16
|
-
- uses: actions/setup-node@
|
|
15
|
+
- uses: actions/checkout@v6
|
|
16
|
+
- uses: actions/setup-node@v6
|
|
17
17
|
with:
|
|
18
18
|
node-version: 22
|
|
19
19
|
registry-url: https://registry.npmjs.org
|
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
pypi:
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
26
|
steps:
|
|
27
|
-
- uses: actions/checkout@
|
|
27
|
+
- uses: actions/checkout@v6
|
|
28
28
|
- uses: astral-sh/setup-uv@v7
|
|
29
29
|
- run: uv build
|
|
30
30
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
package/templates/npm/ci.yml.tpl
CHANGED
|
@@ -13,8 +13,8 @@ jobs:
|
|
|
13
13
|
matrix:
|
|
14
14
|
node-version: [18, 20, 22]
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
17
|
-
- uses: actions/setup-node@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
|
+
- uses: actions/setup-node@v6
|
|
18
18
|
with:
|
|
19
19
|
node-version: ${{ matrix.node-version }}
|
|
20
20
|
- run: node -e "require('./package.json')"
|
|
@@ -8,7 +8,7 @@ This project uses [rlsbl](https://github.com/smm-h/rlsbl) for release orchestrat
|
|
|
8
8
|
- Run `rlsbl release [patch|minor|major]` to bump version and create a GitHub Release
|
|
9
9
|
- CI handles publishing automatically via the publish workflow
|
|
10
10
|
- Never publish manually — always use `rlsbl release`
|
|
11
|
-
-
|
|
11
|
+
- {{publishSetup}}
|
|
12
12
|
- Use `rlsbl release --dry-run` to preview a release without making changes
|
|
13
13
|
|
|
14
14
|
## Conventions
|