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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
package/rlsbl/__init__.py CHANGED
@@ -3,11 +3,40 @@
3
3
  import os
4
4
  import sys
5
5
 
6
- try:
7
- from importlib.metadata import version as _get_version
8
- __version__ = _get_version("rlsbl")
9
- except Exception:
10
- __version__ = "unknown"
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 all
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
- all_registries = ["npm", "pypi", "go"]
153
- for i, r in enumerate(all_registries):
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(all_registries) - 1:
187
+ if i < len(default_registries) - 1:
156
188
  print("")
157
189
  elif command == "scaffold":
158
190
  if registry:
@@ -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": "available"|"taken"|"error", "message"?: str}.
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": "taken"}
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 {"status": "available"}
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 availability on pkg.go.dev."""
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"] == "available":
197
- print(f'"{name}" is available on pkg.go.dev.')
203
+ if result["status"] == "not_found":
204
+ print(f'"{name}" not found on pkg.go.dev.')
198
205
  else:
199
- print(f'"{name}" already exists on pkg.go.dev.')
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 existing_lines and not line.startswith("#")
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:
@@ -86,6 +86,7 @@ def get_template_vars(dir_path):
86
86
  "author": author,
87
87
  "repoName": repo_name,
88
88
  "binCommand": short_name,
89
+ "publishSetup": "GoReleaser handles binary publishing via GitHub Actions (no secrets needed)",
89
90
  }
90
91
 
91
92
 
@@ -84,6 +84,7 @@ def get_template_vars(dir_path):
84
84
  "binCommand": bin_command,
85
85
  "author": pkg.get("author", ""),
86
86
  "repoName": repo_name,
87
+ "publishSetup": "Requires NPM_TOKEN secret on GitHub (Settings > Secrets > Actions)",
87
88
  }
88
89
 
89
90
 
@@ -136,6 +136,7 @@ def get_template_vars(dir_path):
136
136
  "author": author,
137
137
  "repoName": repo_name,
138
138
  "importName": import_name,
139
+ "publishSetup": "Configure Trusted Publishing on pypi.org for automated PyPI releases",
139
140
  }
140
141
 
141
142
 
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 sends a desktop notification."""
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", "{commit_sha}", "--limit", "1",
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
- ok = result.returncode == 0
156
- title = "{label}: CI passed" if ok else "{label}: CI FAILED"
157
- {notify_cmd}
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 _notify_command():
169
- """Return a Python code snippet for sending a desktop notification, or None."""
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
- ' f\'display notification "{summary}" with title "{title}"\'],'
174
- ' timeout=5)'
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 None
214
+ return f"{indent}pass"
182
215
 
183
216
 
184
217
  def bump_version(version, bump_type):
@@ -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@v5
17
- - uses: actions/setup-go@v5
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@v5
14
+ - uses: actions/checkout@v6
15
15
  with:
16
16
  fetch-depth: 0
17
- - uses: actions/setup-go@v5
17
+ - uses: actions/setup-go@v6
18
18
  with:
19
19
  go-version-file: go.mod
20
- - uses: goreleaser/goreleaser-action@v6
20
+ - uses: goreleaser/goreleaser-action@v7
21
21
  with:
22
- version: latest
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@v5
16
- - uses: actions/setup-node@v5
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@v5
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
@@ -13,8 +13,8 @@ jobs:
13
13
  matrix:
14
14
  node-version: [18, 20, 22]
15
15
  steps:
16
- - uses: actions/checkout@v5
17
- - uses: actions/setup-node@v5
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')"
@@ -12,8 +12,8 @@ jobs:
12
12
  publish:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v5
16
- - uses: actions/setup-node@v5
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
@@ -13,7 +13,7 @@ jobs:
13
13
  matrix:
14
14
  python-version: ["3.12", "3.13", "3.14"]
15
15
  steps:
16
- - uses: actions/checkout@v5
16
+ - uses: actions/checkout@v6
17
17
  - uses: astral-sh/setup-uv@v7
18
18
  - run: uv python install ${{ matrix.python-version }}
19
19
  - run: uv sync
@@ -12,7 +12,7 @@ jobs:
12
12
  publish:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/checkout@v5
15
+ - uses: actions/checkout@v6
16
16
  - uses: astral-sh/setup-uv@v7
17
17
  - run: uv build
18
18
  - uses: pypa/gh-action-pypi-publish@release/v1
@@ -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
- - Requires `NPM_TOKEN` secret on GitHub (for npm projects)
11
+ - {{publishSetup}}
12
12
  - Use `rlsbl release --dry-run` to preview a release without making changes
13
13
 
14
14
  ## Conventions
@@ -14,7 +14,7 @@ if ! command -v vhs &>/dev/null; then
14
14
  exit 1
15
15
  fi
16
16
 
17
- TAPE=$(mktemp /tmp/record-XXXX.tape)
17
+ TAPE=$(mktemp)
18
18
  cat > "$TAPE" <<EOF
19
19
  Set FontFamily "monospace"
20
20
  Set FontSize 24